Add NixOS VM configuration and new media search endpoint
Some checks failed
Test Suite / test (push) Failing after 45s

- Introduced a new NixOS VM configuration in `flake.nix` for testing purposes.
- Updated CI workflow to build the VM using the flake input for a more streamlined process.
- Added a new API endpoint `/search` in the collection API to search owned media by title, with optional filtering by media type.
- Refactored frontend components to utilize the new media search functionality, updating types and state management accordingly.
- Enhanced CSS for the map component to ensure proper rendering in various layouts.
This commit is contained in:
Danilo Reyes
2025-12-28 23:02:48 -06:00
parent 4709a05ad4
commit 1329958b4e
7 changed files with 125 additions and 20 deletions

View File

@@ -16,12 +16,8 @@ jobs:
- name: Build NixOS VM - name: Build NixOS VM
run: | run: |
# Build the VM configuration using nixos-rebuild # Build the VM purely via the flake input nixpkgs (no host channels / no <nixpkgs> path)
# This creates a VM that can be run with QEMU nix build .#nixosConfigurations.test-vm.config.system.build.vm -o vm-result
nixos-rebuild build-vm \
-I nixos-config=./nix/test-vm.nix \
-I nixpkgs=<nixpkgs> \
-o vm-result
- name: Start VM - name: Start VM
run: | run: |

View File

@@ -2,7 +2,7 @@
from fastapi import APIRouter, Query, HTTPException from fastapi import APIRouter, Query, HTTPException
from typing import List, Optional from typing import List, Optional
import json import json
from app.core.database import init_db, pool as db_pool from app.core.database import init_db
router = APIRouter() router = APIRouter()
@@ -73,6 +73,7 @@ async def get_media_by_country(
Returns media items with their details. Returns media items with their details.
""" """
await init_db() await init_db()
from app.core.database import pool as db_pool
if db_pool is None: if db_pool is None:
raise HTTPException(status_code=503, detail="Database not available") raise HTTPException(status_code=503, detail="Database not available")
@@ -129,3 +130,65 @@ async def get_media_by_country(
"items": items "items": items
} }
@router.get("/search")
async def search_owned_media(
query: str = Query(..., min_length=1, description="Search query (title substring)"),
media_type: Optional[str] = Query(None, description="Filter by media type: movie or show"),
limit: int = Query(10, ge=1, le=50),
):
"""
Search owned media (from your synced Radarr/Sonarr library) by title.
"""
await init_db()
from app.core.database import pool as db_pool
if db_pool is None:
raise HTTPException(status_code=503, detail="Database not available")
mt = None
if media_type is not None:
if media_type not in ["movie", "show"]:
raise HTTPException(status_code=400, detail="media_type must be 'movie' or 'show'")
mt = media_type
like = f"%{query.strip()}%"
async with db_pool.connection() as conn:
async with conn.cursor() as cur:
if mt:
sql = """
SELECT id, title, year, media_type
FROM moviemap.media_item
WHERE media_type = %s
AND title ILIKE %s
ORDER BY title
LIMIT %s
"""
params = (mt, like, limit)
else:
sql = """
SELECT id, title, year, media_type
FROM moviemap.media_item
WHERE media_type = ANY(%s)
AND title ILIKE %s
ORDER BY title
LIMIT %s
"""
params = (["movie", "show"], like, limit)
await cur.execute(sql, params)
rows = await cur.fetchall()
return {
"query": query,
"results": [
{
"id": str(r[0]),
"title": r[1],
"year": r[2],
"media_type": r[3],
}
for r in rows
],
}

View File

@@ -76,6 +76,13 @@
frontend = frontend; frontend = frontend;
}; };
nixosConfigurations.test-vm = nixpkgs.lib.nixosSystem {
inherit system;
modules = [
./nix/test-vm.nix
];
};
devShells.${system}.default = pkgs.mkShell { devShells.${system}.default = pkgs.mkShell {
buildInputs = [ buildInputs = [
pythonEnv pythonEnv

View File

@@ -1,12 +1,12 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { searchTMDB, TMDBResult } from '../utils/tmdb' import { searchOwnedMedia, OwnedMediaResult } from '../utils/ownedMedia'
import './AutocompleteInput.css' import './AutocompleteInput.css'
interface AutocompleteInputProps { interface AutocompleteInputProps {
value: string value: string
onChange: (value: string) => void onChange: (value: string) => void
onSelect: (result: TMDBResult) => void onSelect: (result: OwnedMediaResult) => void
type: 'movie' | 'tv' type: 'movie' | 'show'
placeholder?: string placeholder?: string
disabled?: boolean disabled?: boolean
} }
@@ -19,7 +19,7 @@ export default function AutocompleteInput({
placeholder = 'Search...', placeholder = 'Search...',
disabled = false, disabled = false,
}: AutocompleteInputProps) { }: AutocompleteInputProps) {
const [suggestions, setSuggestions] = useState<TMDBResult[]>([]) const [suggestions, setSuggestions] = useState<OwnedMediaResult[]>([])
const [showSuggestions, setShowSuggestions] = useState(false) const [showSuggestions, setShowSuggestions] = useState(false)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
@@ -41,7 +41,7 @@ export default function AutocompleteInput({
const performSearch = async (query: string) => { const performSearch = async (query: string) => {
setLoading(true) setLoading(true)
try { try {
const results = await searchTMDB(query, type) const results = await searchOwnedMedia(query, type)
setSuggestions(results) setSuggestions(results)
setShowSuggestions(results.length > 0) setShowSuggestions(results.length > 0)
} catch (error) { } catch (error) {
@@ -53,7 +53,7 @@ export default function AutocompleteInput({
} }
} }
const handleSelect = (result: TMDBResult) => { const handleSelect = (result: OwnedMediaResult) => {
onChange(result.title) onChange(result.title)
onSelect(result) onSelect(result)
setShowSuggestions(false) setShowSuggestions(false)
@@ -89,7 +89,7 @@ export default function AutocompleteInput({
<div ref={suggestionsRef} className="autocomplete-suggestions"> <div ref={suggestionsRef} className="autocomplete-suggestions">
{suggestions.map((result) => ( {suggestions.map((result) => (
<div <div
key={`${result.type}-${result.id}`} key={result.id}
className="autocomplete-suggestion" className="autocomplete-suggestion"
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault() e.preventDefault()
@@ -100,9 +100,6 @@ export default function AutocompleteInput({
{result.title} {result.title}
{result.year && <span className="suggestion-year"> ({result.year})</span>} {result.year && <span className="suggestion-year"> ({result.year})</span>}
</div> </div>
{result.overview && (
<div className="suggestion-overview">{result.overview.substring(0, 100)}...</div>
)}
</div> </div>
))} ))}
</div> </div>

View File

@@ -1,6 +1,7 @@
.map-container { .map-container {
display: flex; display: flex;
height: 100%; /* Ensure maps render even if parent layout doesn't provide an explicit height */
height: calc(100vh - 72px);
width: 100%; width: 100%;
} }

View File

@@ -4,7 +4,7 @@ import { LatLngExpression } from 'leaflet'
import 'leaflet/dist/leaflet.css' import 'leaflet/dist/leaflet.css'
import './Map.css' import './Map.css'
import AutocompleteInput from './AutocompleteInput' import AutocompleteInput from './AutocompleteInput'
import { TMDBResult } from '../utils/tmdb' import { OwnedMediaResult } from '../utils/ownedMedia'
interface WatchedItem { interface WatchedItem {
id: string id: string
@@ -132,7 +132,7 @@ export default function WatchedMap() {
} }
} }
const handleAutocompleteSelect = (result: TMDBResult) => { const handleAutocompleteSelect = (result: OwnedMediaResult) => {
setNewItem({ setNewItem({
...newItem, ...newItem,
title: result.title, title: result.title,

View File

@@ -0,0 +1,41 @@
/**
* Owned media search (backed by Movie Map DB via backend)
*/
export interface OwnedMediaResult {
id: string
title: string
year: number | null
media_type: 'movie' | 'show'
}
export interface OwnedMediaSearchResponse {
query: string
results: OwnedMediaResult[]
}
export async function searchOwnedMedia(
query: string,
mediaType: 'movie' | 'show',
limit: number = 10
): Promise<OwnedMediaResult[]> {
const q = query.trim()
if (!q) return []
const params = new URLSearchParams({
query: q,
media_type: mediaType,
limit: String(limit),
})
try {
const res = await fetch(`/api/collection/search?${params.toString()}`)
if (!res.ok) return []
const data: OwnedMediaSearchResponse = await res.json()
return data.results || []
} catch {
return []
}
}