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
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=<nixpkgs> \
-o vm-result
# Build the VM purely via the flake input nixpkgs (no host channels / no <nixpkgs> path)
nix build .#nixosConfigurations.test-vm.config.system.build.vm -o vm-result
- name: Start VM
run: |

View File

@@ -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
],
}

View File

@@ -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

View File

@@ -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<TMDBResult[]>([])
const [suggestions, setSuggestions] = useState<OwnedMediaResult[]>([])
const [showSuggestions, setShowSuggestions] = useState(false)
const [loading, setLoading] = useState(false)
const inputRef = useRef<HTMLInputElement>(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({
<div ref={suggestionsRef} className="autocomplete-suggestions">
{suggestions.map((result) => (
<div
key={`${result.type}-${result.id}`}
key={result.id}
className="autocomplete-suggestion"
onMouseDown={(e) => {
e.preventDefault()
@@ -100,9 +100,6 @@ export default function AutocompleteInput({
{result.title}
{result.year && <span className="suggestion-year"> ({result.year})</span>}
</div>
{result.overview && (
<div className="suggestion-overview">{result.overview.substring(0, 100)}...</div>
)}
</div>
))}
</div>

View File

@@ -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%;
}

View File

@@ -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,

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 []
}
}