Add country metadata extraction and assignment features

- Updated `README.md` to include instructions for setting up the TMDB API key and new admin endpoints for managing country metadata.
- Implemented `/admin/missing-countries` endpoint to list media items without country metadata, with filtering options for source and media type.
- Added `/admin/assign-country` endpoint to manually assign a country code to a media item.
- Enhanced country extraction logic in `sync.py` to utilize TMDB and MusicBrainz APIs for automatic country retrieval based on available metadata.
- Updated configuration in `config.py` to include optional TMDB API key setting.
- Improved error handling and logging for country extraction failures.
- Ensured that country data is stored and utilized during media item synchronization across Radarr, Sonarr, and Lidarr.
This commit is contained in:
Danilo Reyes
2025-12-28 21:47:03 -06:00
parent 6cffbef8c6
commit 335a53ee62
4 changed files with 337 additions and 22 deletions

View File

@@ -1,7 +1,8 @@
"""Admin API endpoints"""
from fastapi import APIRouter, HTTPException, Header
from typing import Optional
from fastapi import APIRouter, HTTPException, Header, Query
from typing import Optional, List
from app.core.config import settings
from app.core.database import init_db, pool as db_pool
from app.services.sync import sync_all_arrs
router = APIRouter()
@@ -32,3 +33,132 @@ async def trigger_sync(authorization: Optional[str] = Header(None)):
except Exception as e:
raise HTTPException(status_code=500, detail=f"Sync failed: {str(e)}")
@router.get("/missing-countries")
async def get_missing_countries(
authorization: Optional[str] = Header(None),
source_kind: Optional[str] = Query(None, description="Filter by source: radarr, sonarr, lidarr"),
media_type: Optional[str] = Query(None, description="Filter by media type: movie, show, music"),
limit: int = Query(100, ge=1, le=1000)
):
"""
Get list of media items without country metadata.
Requires admin token if MOVIEMAP_ADMIN_TOKEN is set.
"""
await verify_admin_token(authorization)
await init_db()
if db_pool is None:
raise HTTPException(status_code=503, detail="Database not available")
async with db_pool.connection() as conn:
async with conn.cursor() as cur:
# Get total count
count_query = """
SELECT COUNT(DISTINCT mi.id)
FROM moviemap.media_item mi
LEFT JOIN moviemap.media_country mc ON mi.id = mc.media_item_id
WHERE mc.media_item_id IS NULL
"""
count_params = []
if source_kind:
count_query += " AND mi.source_kind = %s"
count_params.append(source_kind)
if media_type:
count_query += " AND mi.media_type = %s"
count_params.append(media_type)
await cur.execute(count_query, count_params if count_params else None)
total_count = (await cur.fetchone())[0]
# Get items
query = """
SELECT
mi.id,
mi.source_kind,
mi.source_item_id,
mi.title,
mi.year,
mi.media_type
FROM moviemap.media_item mi
LEFT JOIN moviemap.media_country mc ON mi.id = mc.media_item_id
WHERE mc.media_item_id IS NULL
"""
params = []
if source_kind:
query += " AND mi.source_kind = %s"
params.append(source_kind)
if media_type:
query += " AND mi.media_type = %s"
params.append(media_type)
query += " ORDER BY mi.title LIMIT %s"
params.append(limit)
await cur.execute(query, params)
rows = await cur.fetchall()
items = []
for row in rows:
items.append({
"id": str(row[0]),
"source_kind": row[1],
"source_item_id": row[2],
"title": row[3],
"year": row[4],
"media_type": row[5],
})
return {
"total": total_count,
"returned": len(items),
"items": items
}
@router.post("/assign-country")
async def assign_country_manually(
item_id: str,
country_code: str,
authorization: Optional[str] = Header(None)
):
"""
Manually assign a country code to a media item.
Requires admin token if MOVIEMAP_ADMIN_TOKEN is set.
"""
await verify_admin_token(authorization)
await init_db()
if db_pool is None:
raise HTTPException(status_code=503, detail="Database not available")
# Validate country code (should be 2 letters)
if len(country_code) != 2 or not country_code.isalpha():
raise HTTPException(status_code=400, detail="Country code must be 2 letters (ISO 3166-1 alpha-2)")
country_code = country_code.upper()
async with db_pool.connection() as conn:
async with conn.cursor() as cur:
# Check if item exists
await cur.execute("SELECT id FROM moviemap.media_item WHERE id = %s", (item_id,))
if not await cur.fetchone():
raise HTTPException(status_code=404, detail="Media item not found")
# Insert or update country association
await cur.execute(
"""
INSERT INTO moviemap.media_country (media_item_id, country_code)
VALUES (%s, %s)
ON CONFLICT (media_item_id, country_code) DO NOTHING
""",
(item_id, country_code)
)
await conn.commit()
return {
"status": "success",
"item_id": item_id,
"country_code": country_code
}