This commit is contained in:
Danilo Reyes
2025-12-28 20:59:09 -06:00
commit 96fcc2b9e8
35 changed files with 2603 additions and 0 deletions

0
backend/app/__init__.py Normal file
View File

View File

35
backend/app/api/admin.py Normal file
View File

@@ -0,0 +1,35 @@
"""Admin API endpoints"""
from fastapi import APIRouter, HTTPException, Header
from typing import Optional
from app.core.database import pool
from app.core.config import settings
from app.services.sync import sync_all_arrs
router = APIRouter()
async def verify_admin_token(authorization: Optional[str] = Header(None)):
"""Verify admin token if configured"""
if settings.admin_token:
if not authorization or authorization != f"Bearer {settings.admin_token}":
raise HTTPException(status_code=401, detail="Unauthorized")
# If no admin token configured, allow (assuming localhost-only access)
@router.post("/sync")
async def trigger_sync(authorization: Optional[str] = Header(None)):
"""
Trigger sync from all *arr instances.
Requires admin token if MOVIEMAP_ADMIN_TOKEN is set.
"""
await verify_admin_token(authorization)
try:
result = await sync_all_arrs()
return {
"status": "success",
"synced": result
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Sync failed: {str(e)}")

View File

@@ -0,0 +1,61 @@
"""Collection API endpoints"""
from fastapi import APIRouter, Query
from typing import List, Optional
from app.core.database import pool
import json
router = APIRouter()
@router.get("/summary")
async def get_collection_summary(
types: Optional[str] = Query(None, description="Comma-separated list: movie,show,music")
):
"""
Get collection summary by country and media type.
Returns counts per country per media type.
"""
# Pool should be initialized on startup, but check just in case
if not pool:
from app.core.database import init_db
await init_db()
# Parse types filter
type_filter = []
if types:
type_filter = [t.strip() for t in types.split(",") if t.strip() in ["movie", "show", "music"]]
async with pool.connection() as conn:
async with conn.cursor() as cur:
# Build query
query = """
SELECT
mc.country_code,
mi.media_type,
COUNT(*) as count
FROM moviemap.media_country mc
JOIN moviemap.media_item mi ON mc.media_item_id = mi.id
"""
params = []
if type_filter:
query += " WHERE mi.media_type = ANY(%s)"
params.append(type_filter)
query += """
GROUP BY mc.country_code, mi.media_type
ORDER BY mc.country_code, mi.media_type
"""
await cur.execute(query, params if params else None)
rows = await cur.fetchall()
# Transform to nested dict structure
result = {}
for row in rows:
country_code, media_type, count = row
if country_code not in result:
result[country_code] = {}
result[country_code][media_type] = count
return result

87
backend/app/api/pins.py Normal file
View File

@@ -0,0 +1,87 @@
"""Manual pins API endpoints"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import Optional
from uuid import UUID
from app.core.database import pool
router = APIRouter()
class PinCreate(BaseModel):
country_code: str
label: Optional[str] = None
@router.get("")
async def list_pins():
"""List all manual pins"""
# Pool should be initialized on startup
if not pool:
from app.core.database import init_db
await init_db()
async with pool.connection() as conn:
async with conn.cursor() as cur:
query = """
SELECT id, country_code, label, pinned_at
FROM moviemap.manual_pin
ORDER BY pinned_at DESC
"""
await cur.execute(query)
rows = await cur.fetchall()
pins = []
for row in rows:
pins.append({
"id": str(row[0]),
"country_code": row[1],
"label": row[2],
"pinned_at": row[3].isoformat() if row[3] else None,
})
return pins
@router.post("")
async def create_pin(pin: PinCreate):
"""Create a new manual pin"""
# Pool should be initialized on startup
if not pool:
from app.core.database import init_db
await init_db()
async with pool.connection() as conn:
async with conn.cursor() as cur:
query = """
INSERT INTO moviemap.manual_pin (country_code, label)
VALUES (%s, %s)
RETURNING id
"""
await cur.execute(query, (pin.country_code, pin.label))
result = await cur.fetchone()
await conn.commit()
return {"id": str(result[0]), "status": "created"}
@router.delete("/{pin_id}")
async def delete_pin(pin_id: UUID):
"""Delete a manual pin"""
# Pool should be initialized on startup
if not pool:
from app.core.database import init_db
await init_db()
async with pool.connection() as conn:
async with conn.cursor() as cur:
query = "DELETE FROM moviemap.manual_pin WHERE id = %s RETURNING id"
await cur.execute(query, (str(pin_id),))
result = await cur.fetchone()
await conn.commit()
if not result:
raise HTTPException(status_code=404, detail="Pin not found")
return {"id": str(result[0]), "status": "deleted"}

208
backend/app/api/watched.py Normal file
View File

@@ -0,0 +1,208 @@
"""Watched items API endpoints"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
from uuid import UUID
from app.core.database import pool
import json
router = APIRouter()
class WatchedItemCreate(BaseModel):
media_type: str # "movie" or "show"
title: str
year: Optional[int] = None
country_code: str
watched_at: Optional[datetime] = None
notes: Optional[str] = None
class WatchedItemUpdate(BaseModel):
title: Optional[str] = None
year: Optional[int] = None
country_code: Optional[str] = None
watched_at: Optional[datetime] = None
notes: Optional[str] = None
@router.get("/summary")
async def get_watched_summary():
"""Get watched items summary by country"""
# Pool should be initialized on startup
if not pool:
from app.core.database import init_db
await init_db()
async with pool.connection() as conn:
async with conn.cursor() as cur:
query = """
SELECT
country_code,
media_type,
COUNT(*) as count
FROM moviemap.watched_item
WHERE watched_at IS NOT NULL
GROUP BY country_code, media_type
ORDER BY country_code, media_type
"""
await cur.execute(query)
rows = await cur.fetchall()
result = {}
for row in rows:
country_code, media_type, count = row
if country_code not in result:
result[country_code] = {}
result[country_code][media_type] = count
return result
@router.get("")
async def list_watched_items():
"""List all watched items"""
# Pool should be initialized on startup
if not pool:
from app.core.database import init_db
await init_db()
async with pool.connection() as conn:
async with conn.cursor() as cur:
query = """
SELECT
id, media_type, title, year, country_code,
watched_at, notes, created_at, updated_at
FROM moviemap.watched_item
ORDER BY created_at DESC
"""
await cur.execute(query)
rows = await cur.fetchall()
items = []
for row in rows:
items.append({
"id": str(row[0]),
"media_type": row[1],
"title": row[2],
"year": row[3],
"country_code": row[4],
"watched_at": row[5].isoformat() if row[5] else None,
"notes": row[6],
"created_at": row[7].isoformat() if row[7] else None,
"updated_at": row[8].isoformat() if row[8] else None,
})
return items
@router.post("")
async def create_watched_item(item: WatchedItemCreate):
"""Create a new watched item"""
# Pool should be initialized on startup
if not pool:
from app.core.database import init_db
await init_db()
if item.media_type not in ["movie", "show"]:
raise HTTPException(status_code=400, detail="media_type must be 'movie' or 'show'")
async with pool.connection() as conn:
async with conn.cursor() as cur:
query = """
INSERT INTO moviemap.watched_item
(media_type, title, year, country_code, watched_at, notes)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id
"""
await cur.execute(
query,
(
item.media_type,
item.title,
item.year,
item.country_code,
item.watched_at,
item.notes,
)
)
result = await cur.fetchone()
await conn.commit()
return {"id": str(result[0]), "status": "created"}
@router.patch("/{item_id}")
async def update_watched_item(item_id: UUID, item: WatchedItemUpdate):
"""Update a watched item"""
# Pool should be initialized on startup
if not pool:
from app.core.database import init_db
await init_db()
async with pool.connection() as conn:
async with conn.cursor() as cur:
# Build dynamic update query
updates = []
params = []
if item.title is not None:
updates.append("title = %s")
params.append(item.title)
if item.year is not None:
updates.append("year = %s")
params.append(item.year)
if item.country_code is not None:
updates.append("country_code = %s")
params.append(item.country_code)
if item.watched_at is not None:
updates.append("watched_at = %s")
params.append(item.watched_at)
if item.notes is not None:
updates.append("notes = %s")
params.append(item.notes)
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
updates.append("updated_at = NOW()")
params.append(str(item_id))
query = f"""
UPDATE moviemap.watched_item
SET {', '.join(updates)}
WHERE id = %s
RETURNING id
"""
await cur.execute(query, params)
result = await cur.fetchone()
await conn.commit()
if not result:
raise HTTPException(status_code=404, detail="Watched item not found")
return {"id": str(result[0]), "status": "updated"}
@router.delete("/{item_id}")
async def delete_watched_item(item_id: UUID):
"""Delete a watched item"""
# Pool should be initialized on startup
if not pool:
from app.core.database import init_db
await init_db()
async with pool.connection() as conn:
async with conn.cursor() as cur:
query = "DELETE FROM moviemap.watched_item WHERE id = %s RETURNING id"
await cur.execute(query, (str(item_id),))
result = await cur.fetchone()
await conn.commit()
if not result:
raise HTTPException(status_code=404, detail="Watched item not found")
return {"id": str(result[0]), "status": "deleted"}

View File

View File

@@ -0,0 +1,43 @@
"""Application configuration"""
from pydantic_settings import BaseSettings
from typing import Optional
import os
class Settings(BaseSettings):
"""Application settings"""
# Server
port: int = int(os.getenv("PORT", "8080"))
host: str = "127.0.0.1"
# Database
postgres_socket_path: str = os.getenv("POSTGRES_SOCKET_PATH", "/run/postgresql")
postgres_db: str = os.getenv("POSTGRES_DB", "jawz")
postgres_user: str = os.getenv("POSTGRES_USER", os.getenv("USER", "jawz"))
# *arr API keys
sonarr_api_key: str = os.getenv("SONARR_API_KEY", "")
radarr_api_key: str = os.getenv("RADARR_API_KEY", "")
lidarr_api_key: str = os.getenv("LIDARR_API_KEY", "")
# *arr base URLs
sonarr_url: str = "http://127.0.0.1:8989"
radarr_url: str = "http://127.0.0.1:7878"
lidarr_url: str = "http://127.0.0.1:8686"
# Admin
admin_token: Optional[str] = os.getenv("MOVIEMAP_ADMIN_TOKEN")
@property
def database_url(self) -> str:
"""Build PostgreSQL connection string using Unix socket"""
return f"postgresql://{self.postgres_user}@/{self.postgres_db}?host={self.postgres_socket_path}"
class Config:
env_file = ".env"
case_sensitive = False
settings = Settings()

View File

@@ -0,0 +1,50 @@
"""Database connection and session management"""
from psycopg import AsyncConnection
from psycopg_pool import AsyncConnectionPool
from app.core.config import settings
from typing import Optional
import logging
logger = logging.getLogger(__name__)
# Connection pool
pool: Optional[AsyncConnectionPool] = None
async def init_db():
"""Initialize database connection pool"""
global pool
try:
pool = AsyncConnectionPool(
conninfo=settings.database_url,
min_size=1,
max_size=10,
open=False,
)
await pool.open()
logger.info("Database connection pool initialized")
except Exception as e:
logger.error(f"Failed to initialize database pool: {e}")
raise
async def close_db():
"""Close database connection pool"""
global pool
if pool:
await pool.close()
logger.info("Database connection pool closed")
async def get_db() -> AsyncConnection:
"""Get database connection from pool"""
if not pool:
await init_db()
return await pool.getconn()
async def return_conn(conn: AsyncConnection):
"""Return connection to pool"""
if pool:
await pool.putconn(conn)

View File

View File

@@ -0,0 +1,296 @@
"""Sync service for *arr instances"""
import httpx
import logging
from typing import Dict, List, Optional
from app.core.config import settings
from app.core.database import pool
import json
logger = logging.getLogger(__name__)
async def fetch_radarr_movies() -> List[Dict]:
"""Fetch all movies from Radarr"""
if not settings.radarr_api_key:
logger.warning("Radarr API key not configured")
return []
async with httpx.AsyncClient() as client:
try:
response = await client.get(
f"{settings.radarr_url}/api/v3/movie",
headers={"X-Api-Key": settings.radarr_api_key},
timeout=30.0
)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Failed to fetch Radarr movies: {e}")
return []
async def fetch_sonarr_series() -> List[Dict]:
"""Fetch all series from Sonarr"""
if not settings.sonarr_api_key:
logger.warning("Sonarr API key not configured")
return []
async with httpx.AsyncClient() as client:
try:
response = await client.get(
f"{settings.sonarr_url}/api/v3/series",
headers={"X-Api-Key": settings.sonarr_api_key},
timeout=30.0
)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Failed to fetch Sonarr series: {e}")
return []
async def fetch_lidarr_artists() -> List[Dict]:
"""Fetch all artists from Lidarr"""
if not settings.lidarr_api_key:
logger.warning("Lidarr API key not configured")
return []
async with httpx.AsyncClient() as client:
try:
response = await client.get(
f"{settings.lidarr_url}/api/v1/artist",
headers={"X-Api-Key": settings.lidarr_api_key},
timeout=30.0
)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Failed to fetch Lidarr artists: {e}")
return []
def extract_country_from_radarr(movie: Dict) -> Optional[str]:
"""Extract country code from Radarr movie metadata"""
# Try productionCountries first
if "productionCountries" in movie and movie["productionCountries"]:
countries = movie["productionCountries"]
if isinstance(countries, list) and len(countries) > 0:
country = countries[0]
if isinstance(country, dict) and "iso_3166_1" in country:
return country["iso_3166_1"].upper()
elif isinstance(country, str):
# Try to map country name to code (simplified)
return None # Would need a mapping table
# Try to get from TMDB metadata if available
if "tmdbId" in movie and movie.get("movieMetadata", {}).get("productionCountries"):
countries = movie["movieMetadata"]["productionCountries"]
if isinstance(countries, list) and len(countries) > 0:
country = countries[0]
if isinstance(country, dict) and "iso_3166_1" in country:
return country["iso_3166_1"].upper()
return None
def extract_country_from_sonarr(series: Dict) -> Optional[str]:
"""Extract country code from Sonarr series metadata"""
# Sonarr doesn't always have country info directly
# Check network origin or other metadata
if "network" in series and series["network"]:
# Network name might hint at country, but not reliable
pass
# Check if there's any country metadata
if "seriesMetadata" in series:
metadata = series["seriesMetadata"]
if "originCountry" in metadata and metadata["originCountry"]:
# originCountry might be a list or string
origin = metadata["originCountry"]
if isinstance(origin, list) and len(origin) > 0:
return origin[0].upper() if len(origin[0]) == 2 else None
elif isinstance(origin, str) and len(origin) == 2:
return origin.upper()
return None
def extract_country_from_lidarr(artist: Dict) -> Optional[str]:
"""Extract country code from Lidarr artist metadata"""
# Lidarr has a country field
if "country" in artist and artist["country"]:
country = artist["country"]
if isinstance(country, str) and len(country) == 2:
return country.upper()
# Might be a country name, would need mapping
return None
async def upsert_media_item(source_kind: str, source_item_id: int, title: str,
year: Optional[int], media_type: str, arr_raw: Dict):
"""Upsert a media item into the database"""
# Pool should be initialized on startup
if not pool:
from app.core.database import init_db
await init_db()
async with pool.connection() as conn:
async with conn.cursor() as cur:
# Upsert media item
query = """
INSERT INTO moviemap.media_item
(source_kind, source_item_id, title, year, media_type, arr_raw)
VALUES (%s, %s, %s, %s, %s, %s::jsonb)
ON CONFLICT (source_kind, source_item_id)
DO UPDATE SET
title = EXCLUDED.title,
year = EXCLUDED.year,
arr_raw = EXCLUDED.arr_raw
RETURNING id
"""
await cur.execute(
query,
(source_kind, source_item_id, title, year, media_type, json.dumps(arr_raw))
)
result = await cur.fetchone()
media_item_id = result[0]
# Extract and upsert country
country_code = None
if source_kind == "radarr":
country_code = extract_country_from_radarr(arr_raw)
elif source_kind == "sonarr":
country_code = extract_country_from_sonarr(arr_raw)
elif source_kind == "lidarr":
country_code = extract_country_from_lidarr(arr_raw)
# Delete existing country associations
await cur.execute(
"DELETE FROM moviemap.media_country WHERE media_item_id = %s",
(media_item_id,)
)
# Insert new country association if found
if country_code:
await cur.execute(
"INSERT INTO moviemap.media_country (media_item_id, country_code) VALUES (%s, %s)",
(media_item_id, country_code)
)
await conn.commit()
return media_item_id
async def sync_radarr():
"""Sync movies from Radarr"""
movies = await fetch_radarr_movies()
synced = 0
for movie in movies:
try:
await upsert_media_item(
source_kind="radarr",
source_item_id=movie.get("id"),
title=movie.get("title", "Unknown"),
year=movie.get("year"),
media_type="movie",
arr_raw=movie
)
synced += 1
except Exception as e:
logger.error(f"Failed to sync movie {movie.get('id')}: {e}")
return {"radarr": synced}
async def sync_sonarr():
"""Sync series from Sonarr"""
series = await fetch_sonarr_series()
synced = 0
for s in series:
try:
await upsert_media_item(
source_kind="sonarr",
source_item_id=s.get("id"),
title=s.get("title", "Unknown"),
year=s.get("year"),
media_type="show",
arr_raw=s
)
synced += 1
except Exception as e:
logger.error(f"Failed to sync series {s.get('id')}: {e}")
return {"sonarr": synced}
async def sync_lidarr():
"""Sync artists from Lidarr"""
artists = await fetch_lidarr_artists()
synced = 0
for artist in artists:
try:
await upsert_media_item(
source_kind="lidarr",
source_item_id=artist.get("id"),
title=artist.get("artistName", "Unknown"),
year=None, # Artists don't have a year
media_type="music",
arr_raw=artist
)
synced += 1
except Exception as e:
logger.error(f"Failed to sync artist {artist.get('id')}: {e}")
return {"lidarr": synced}
async def sync_all_arrs() -> Dict:
"""Sync from all *arr instances"""
logger.info("Starting sync from all *arr instances")
results = {}
# Sync each service
try:
results.update(await sync_radarr())
except Exception as e:
logger.error(f"Radarr sync failed: {e}")
results["radarr"] = 0
try:
results.update(await sync_sonarr())
except Exception as e:
logger.error(f"Sonarr sync failed: {e}")
results["sonarr"] = 0
try:
results.update(await sync_lidarr())
except Exception as e:
logger.error(f"Lidarr sync failed: {e}")
results["lidarr"] = 0
# Update last sync time (pool should be initialized)
if not pool:
from app.core.database import init_db
await init_db()
async with pool.connection() as conn:
async with conn.cursor() as cur:
for source_kind in ["radarr", "sonarr", "lidarr"]:
await cur.execute(
"""
INSERT INTO moviemap.source (kind, base_url, enabled, last_sync_at)
VALUES (%s, %s, %s, NOW())
ON CONFLICT (kind) DO UPDATE SET last_sync_at = NOW()
""",
(source_kind, getattr(settings, f"{source_kind}_url"), True)
)
await conn.commit()
logger.info(f"Sync completed: {results}")
return results