diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index fae0144..d2e7b4c 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -16,12 +16,8 @@ jobs: - name: Build NixOS VM run: | - # Build the VM configuration using nixos-rebuild - # This creates a VM that can be run with QEMU - nixos-rebuild build-vm \ - -I nixos-config=./nix/test-vm.nix \ - -I nixpkgs= \ - -o vm-result + # Build the VM purely via the flake input nixpkgs (no host channels / no path) + nix build .#nixosConfigurations.test-vm.config.system.build.vm -o vm-result - name: Start VM run: | diff --git a/backend/app/api/collection.py b/backend/app/api/collection.py index 7b33ffa..67378f6 100644 --- a/backend/app/api/collection.py +++ b/backend/app/api/collection.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Query, HTTPException from typing import List, Optional import json -from app.core.database import init_db, pool as db_pool +from app.core.database import init_db router = APIRouter() @@ -73,6 +73,7 @@ async def get_media_by_country( Returns media items with their details. """ 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") @@ -129,3 +130,65 @@ async def get_media_by_country( "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 + ], + } + diff --git a/flake.nix b/flake.nix index e3a0980..bf7e2a1 100644 --- a/flake.nix +++ b/flake.nix @@ -76,6 +76,13 @@ frontend = frontend; }; + nixosConfigurations.test-vm = nixpkgs.lib.nixosSystem { + inherit system; + modules = [ + ./nix/test-vm.nix + ]; + }; + devShells.${system}.default = pkgs.mkShell { buildInputs = [ pythonEnv diff --git a/frontend/src/components/AutocompleteInput.tsx b/frontend/src/components/AutocompleteInput.tsx index 5b822e1..5091f13 100644 --- a/frontend/src/components/AutocompleteInput.tsx +++ b/frontend/src/components/AutocompleteInput.tsx @@ -1,12 +1,12 @@ import { useState, useEffect, useRef } from 'react' -import { searchTMDB, TMDBResult } from '../utils/tmdb' +import { searchOwnedMedia, OwnedMediaResult } from '../utils/ownedMedia' import './AutocompleteInput.css' interface AutocompleteInputProps { value: string onChange: (value: string) => void - onSelect: (result: TMDBResult) => void - type: 'movie' | 'tv' + onSelect: (result: OwnedMediaResult) => void + type: 'movie' | 'show' placeholder?: string disabled?: boolean } @@ -19,7 +19,7 @@ export default function AutocompleteInput({ placeholder = 'Search...', disabled = false, }: AutocompleteInputProps) { - const [suggestions, setSuggestions] = useState([]) + const [suggestions, setSuggestions] = useState([]) const [showSuggestions, setShowSuggestions] = useState(false) const [loading, setLoading] = useState(false) const inputRef = useRef(null) @@ -41,7 +41,7 @@ export default function AutocompleteInput({ const performSearch = async (query: string) => { setLoading(true) try { - const results = await searchTMDB(query, type) + const results = await searchOwnedMedia(query, type) setSuggestions(results) setShowSuggestions(results.length > 0) } catch (error) { @@ -53,7 +53,7 @@ export default function AutocompleteInput({ } } - const handleSelect = (result: TMDBResult) => { + const handleSelect = (result: OwnedMediaResult) => { onChange(result.title) onSelect(result) setShowSuggestions(false) @@ -89,7 +89,7 @@ export default function AutocompleteInput({
{suggestions.map((result) => (
{ e.preventDefault() @@ -100,9 +100,6 @@ export default function AutocompleteInput({ {result.title} {result.year && ({result.year})}
- {result.overview && ( -
{result.overview.substring(0, 100)}...
- )}
))} diff --git a/frontend/src/components/Map.css b/frontend/src/components/Map.css index eb5a6a2..05b1435 100644 --- a/frontend/src/components/Map.css +++ b/frontend/src/components/Map.css @@ -1,6 +1,7 @@ .map-container { display: flex; - height: 100%; + /* Ensure maps render even if parent layout doesn't provide an explicit height */ + height: calc(100vh - 72px); width: 100%; } diff --git a/frontend/src/components/WatchedMap.tsx b/frontend/src/components/WatchedMap.tsx index dfbbc60..fedfb99 100644 --- a/frontend/src/components/WatchedMap.tsx +++ b/frontend/src/components/WatchedMap.tsx @@ -4,7 +4,7 @@ import { LatLngExpression } from 'leaflet' import 'leaflet/dist/leaflet.css' import './Map.css' import AutocompleteInput from './AutocompleteInput' -import { TMDBResult } from '../utils/tmdb' +import { OwnedMediaResult } from '../utils/ownedMedia' interface WatchedItem { id: string @@ -132,7 +132,7 @@ export default function WatchedMap() { } } - const handleAutocompleteSelect = (result: TMDBResult) => { + const handleAutocompleteSelect = (result: OwnedMediaResult) => { setNewItem({ ...newItem, title: result.title, diff --git a/frontend/src/utils/ownedMedia.ts b/frontend/src/utils/ownedMedia.ts new file mode 100644 index 0000000..d7148e5 --- /dev/null +++ b/frontend/src/utils/ownedMedia.ts @@ -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 { + 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 [] + } +} + +