Add new API endpoints for media retrieval by country and enhance configuration
Some checks failed
Test Suite / test (push) Has been cancelled
Some checks failed
Test Suite / test (push) Has been cancelled
- Introduced `/api/tmdb` and `/api/collection/missing-locations` endpoints to the backend for improved media management. - Added a new `get_media_by_country` function in the collection API to fetch media items based on country codes. - Updated configuration to allow overriding *arr base URLs via environment variables for better flexibility. - Enhanced frontend with a new `MissingLocations` component and integrated it into the routing structure. - Improved the `CollectionMap` component to handle country selection and display media items accordingly. - Added testing dependencies in `requirements.txt` and updated frontend configuration for testing support.
This commit is contained in:
97
.gitea/workflows/test.yml
Normal file
97
.gitea/workflows/test.yml
Normal file
@@ -0,0 +1,97 @@
|
||||
name: Test Suite
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master, develop ]
|
||||
pull_request:
|
||||
branches: [ main, master, develop ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Nix
|
||||
uses: cachix/install-nix-action@v20
|
||||
with:
|
||||
nix: 2.18.1
|
||||
extra_nix_config: |
|
||||
experimental-features = nix-command flakes
|
||||
|
||||
- name: Install Nix dependencies
|
||||
run: |
|
||||
nix profile install nixpkgs#postgresql
|
||||
nix profile install nixpkgs#python3
|
||||
nix profile install nixpkgs#nodejs_20
|
||||
nix profile install nixpkgs#npm
|
||||
|
||||
- name: Setup Python
|
||||
run: |
|
||||
python3 -m pip install --upgrade pip
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
- name: Setup Node.js
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
|
||||
- name: Start PostgreSQL
|
||||
run: |
|
||||
sudo systemctl start postgresql || sudo service postgresql start
|
||||
sudo -u postgres psql -c "CREATE DATABASE moviemap_test;"
|
||||
sudo -u postgres psql -c "CREATE USER moviemap WITH PASSWORD 'test';"
|
||||
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE moviemap_test TO moviemap;"
|
||||
sudo -u postgres psql -c "ALTER USER moviemap CREATEDB;"
|
||||
env:
|
||||
POSTGRES_HOST: localhost
|
||||
POSTGRES_PORT: 5432
|
||||
|
||||
- name: Run database migrations
|
||||
run: |
|
||||
cd backend
|
||||
export POSTGRES_SOCKET_PATH=/var/run/postgresql
|
||||
export POSTGRES_DB=moviemap_test
|
||||
export POSTGRES_USER=moviemap
|
||||
alembic upgrade head
|
||||
env:
|
||||
TEST_POSTGRES_DB: moviemap_test
|
||||
TEST_POSTGRES_USER: moviemap
|
||||
TEST_POSTGRES_SOCKET_PATH: /var/run/postgresql
|
||||
|
||||
- name: Run backend tests
|
||||
run: |
|
||||
cd backend
|
||||
export POSTGRES_SOCKET_PATH=/var/run/postgresql
|
||||
export POSTGRES_DB=moviemap_test
|
||||
export POSTGRES_USER=moviemap
|
||||
export TEST_POSTGRES_DB=moviemap_test
|
||||
export TEST_POSTGRES_USER=moviemap
|
||||
export TEST_POSTGRES_SOCKET_PATH=/var/run/postgresql
|
||||
pytest tests/ -v --cov=app --cov-report=term-missing
|
||||
env:
|
||||
# Mock *arr URLs for testing (tests will mock the actual API calls)
|
||||
RADARR_URL: http://localhost:7878
|
||||
SONARR_URL: http://localhost:8989
|
||||
LIDARR_URL: http://localhost:8686
|
||||
RADARR_API_KEY: test-key
|
||||
SONARR_API_KEY: test-key
|
||||
LIDARR_API_KEY: test-key
|
||||
|
||||
- name: Build frontend
|
||||
run: |
|
||||
cd frontend
|
||||
npm run build
|
||||
|
||||
- name: Run frontend tests
|
||||
run: |
|
||||
cd frontend
|
||||
npm test -- --run
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: |
|
||||
sudo systemctl stop postgresql || sudo service postgresql stop || true
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Collection API endpoints"""
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi import APIRouter, Query, HTTPException
|
||||
from typing import List, Optional
|
||||
import json
|
||||
from app.core.database import init_db, pool as db_pool
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -61,3 +62,70 @@ async def get_collection_summary(
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/by-country")
|
||||
async def get_media_by_country(
|
||||
country_code: str = Query(..., description="ISO 3166-1 alpha-2 country code"),
|
||||
types: Optional[str] = Query(None, description="Comma-separated list: movie,show,music")
|
||||
):
|
||||
"""
|
||||
Get list of media items for a specific country.
|
||||
Returns media items with their details.
|
||||
"""
|
||||
await init_db()
|
||||
if db_pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database not available")
|
||||
|
||||
# Validate country code
|
||||
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()
|
||||
|
||||
# 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 db_pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
query = """
|
||||
SELECT
|
||||
mi.id,
|
||||
mi.source_kind,
|
||||
mi.source_item_id,
|
||||
mi.title,
|
||||
mi.year,
|
||||
mi.media_type
|
||||
FROM moviemap.media_country mc
|
||||
JOIN moviemap.media_item mi ON mc.media_item_id = mi.id
|
||||
WHERE mc.country_code = %s
|
||||
"""
|
||||
params = [country_code]
|
||||
|
||||
if type_filter:
|
||||
query += " AND mi.media_type = ANY(%s)"
|
||||
params.append(type_filter)
|
||||
|
||||
query += " ORDER BY mi.title"
|
||||
|
||||
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 {
|
||||
"country_code": country_code,
|
||||
"count": len(items),
|
||||
"items": items
|
||||
}
|
||||
|
||||
|
||||
87
backend/app/api/missing_locations.py
Normal file
87
backend/app/api/missing_locations.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Missing locations API endpoints"""
|
||||
from fastapi import APIRouter, Query, HTTPException
|
||||
from typing import Optional
|
||||
from app.core.database import init_db, pool as db_pool
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_missing_locations(
|
||||
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, description="Maximum number of items to return"),
|
||||
offset: int = Query(0, ge=0, description="Number of items to skip")
|
||||
):
|
||||
"""
|
||||
Get list of media items without country metadata.
|
||||
Returns paginated list of media items that don't have any country associations.
|
||||
"""
|
||||
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:
|
||||
# Build query
|
||||
where_clauses = ["mc.media_item_id IS NULL"]
|
||||
params = []
|
||||
|
||||
if source_kind:
|
||||
where_clauses.append("mi.source_kind = %s")
|
||||
params.append(source_kind)
|
||||
if media_type:
|
||||
where_clauses.append("mi.media_type = %s")
|
||||
params.append(media_type)
|
||||
|
||||
where_clause = " AND ".join(where_clauses)
|
||||
|
||||
# Get total count
|
||||
count_query = f"""
|
||||
SELECT COUNT(DISTINCT mi.id)
|
||||
FROM moviemap.media_item mi
|
||||
LEFT JOIN moviemap.media_country mc ON mi.id = mc.media_item_id
|
||||
WHERE {where_clause}
|
||||
"""
|
||||
await cur.execute(count_query, params)
|
||||
total_count = (await cur.fetchone())[0]
|
||||
|
||||
# Get items
|
||||
query = f"""
|
||||
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 {where_clause}
|
||||
ORDER BY mi.title
|
||||
LIMIT %s OFFSET %s
|
||||
"""
|
||||
params.extend([limit, offset])
|
||||
|
||||
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),
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"items": items
|
||||
}
|
||||
|
||||
71
backend/app/api/tmdb.py
Normal file
71
backend/app/api/tmdb.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""TMDB API endpoints for searching movies and TV shows"""
|
||||
from fastapi import APIRouter, Query, HTTPException
|
||||
from typing import Optional
|
||||
import httpx
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
TMDB_BASE_URL = "https://api.themoviedb.org/3"
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
async def search_tmdb(
|
||||
query: str = Query(..., description="Search query"),
|
||||
type: str = Query("movie", description="Type: movie or tv")
|
||||
):
|
||||
"""
|
||||
Search TMDB for movies or TV shows.
|
||||
Returns a list of results with title, year, and other metadata.
|
||||
"""
|
||||
if not settings.tmdb_api_key:
|
||||
raise HTTPException(status_code=503, detail="TMDB API key not configured")
|
||||
|
||||
if type not in ["movie", "tv"]:
|
||||
raise HTTPException(status_code=400, detail="Type must be 'movie' or 'tv'")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{TMDB_BASE_URL}/search/{type}",
|
||||
params={
|
||||
"api_key": settings.tmdb_api_key,
|
||||
"query": query,
|
||||
"language": "en-US",
|
||||
},
|
||||
timeout=10.0
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
results = []
|
||||
for item in data.get("results", [])[:10]: # Limit to 10 results
|
||||
result = {
|
||||
"id": item.get("id"),
|
||||
"title": item.get("title") or item.get("name"),
|
||||
"year": None,
|
||||
"type": type,
|
||||
"overview": item.get("overview"),
|
||||
"poster_path": item.get("poster_path"),
|
||||
}
|
||||
|
||||
# Extract year from release_date or first_air_date
|
||||
date_str = item.get("release_date") or item.get("first_air_date")
|
||||
if date_str:
|
||||
try:
|
||||
result["year"] = int(date_str.split("-")[0])
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
results.append(result)
|
||||
|
||||
return {
|
||||
"query": query,
|
||||
"type": type,
|
||||
"results": results
|
||||
}
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise HTTPException(status_code=e.response.status_code, detail=f"TMDB API error: {e.response.text[:200]}")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to search TMDB: {str(e)}")
|
||||
|
||||
@@ -21,10 +21,10 @@ class Settings(BaseSettings):
|
||||
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"
|
||||
# *arr base URLs (can be overridden via environment variables)
|
||||
sonarr_url: str = os.getenv("SONARR_URL", "http://127.0.0.1:8989")
|
||||
radarr_url: str = os.getenv("RADARR_URL", "http://127.0.0.1:7878")
|
||||
lidarr_url: str = os.getenv("LIDARR_URL", "http://127.0.0.1:8686")
|
||||
|
||||
# Admin
|
||||
admin_token: Optional[str] = os.getenv("MOVIEMAP_ADMIN_TOKEN")
|
||||
|
||||
@@ -9,7 +9,7 @@ import os
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from app.api import collection, watched, pins, admin
|
||||
from app.api import collection, watched, pins, admin, tmdb, missing_locations
|
||||
from app.core.config import settings
|
||||
from app.core.database import init_db, close_db
|
||||
|
||||
@@ -45,6 +45,8 @@ app.include_router(collection.router, prefix="/api/collection", tags=["collectio
|
||||
app.include_router(watched.router, prefix="/api/watched", tags=["watched"])
|
||||
app.include_router(pins.router, prefix="/api/pins", tags=["pins"])
|
||||
app.include_router(admin.router, prefix="/admin", tags=["admin"])
|
||||
app.include_router(tmdb.router, prefix="/api/tmdb", tags=["tmdb"])
|
||||
app.include_router(missing_locations.router, prefix="/api/collection/missing-locations", tags=["missing-locations"])
|
||||
|
||||
# Serve frontend static files
|
||||
# Check multiple possible locations (dev, Nix build, etc.)
|
||||
|
||||
@@ -8,4 +8,7 @@ httpx==0.25.2
|
||||
pydantic==2.5.2
|
||||
pydantic-settings==2.1.0
|
||||
python-multipart==0.0.6
|
||||
pytest==7.4.3
|
||||
pytest-asyncio==0.21.1
|
||||
pytest-cov==4.1.0
|
||||
|
||||
|
||||
2
backend/tests/__init__.py
Normal file
2
backend/tests/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Test suite for Movie Map backend"""
|
||||
|
||||
200
backend/tests/conftest.py
Normal file
200
backend/tests/conftest.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""Pytest configuration and fixtures"""
|
||||
import pytest
|
||||
import asyncio
|
||||
import os
|
||||
from typing import AsyncGenerator
|
||||
from fastapi.testclient import TestClient
|
||||
from httpx import AsyncClient
|
||||
from app.core.database import init_db, close_db, pool as db_pool
|
||||
from app.core.config import settings
|
||||
from app.main import app
|
||||
import psycopg
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
|
||||
|
||||
# Use test database
|
||||
TEST_DB = os.getenv("TEST_POSTGRES_DB", "moviemap_test")
|
||||
TEST_USER = os.getenv("TEST_POSTGRES_USER", os.getenv("USER", "jawz"))
|
||||
TEST_SOCKET = os.getenv("TEST_POSTGRES_SOCKET_PATH", "/run/postgresql")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
"""Create an instance of the default event loop for the test session."""
|
||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
async def test_db_pool() -> AsyncGenerator[AsyncConnectionPool, None]:
|
||||
"""Create a test database connection pool"""
|
||||
test_db_url = f"postgresql://{TEST_USER}@/{TEST_DB}?host={TEST_SOCKET}"
|
||||
|
||||
pool = AsyncConnectionPool(
|
||||
conninfo=test_db_url,
|
||||
min_size=1,
|
||||
max_size=5,
|
||||
open=False,
|
||||
)
|
||||
await pool.open()
|
||||
|
||||
# Run migrations
|
||||
async with pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
# Create schema if not exists
|
||||
await cur.execute("CREATE SCHEMA IF NOT EXISTS moviemap")
|
||||
|
||||
# Create enums
|
||||
await cur.execute("""
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE moviemap.source_kind AS ENUM ('radarr', 'sonarr', 'lidarr');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
""")
|
||||
|
||||
await cur.execute("""
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE moviemap.media_type AS ENUM ('movie', 'show', 'music');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
""")
|
||||
|
||||
await cur.execute("""
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE moviemap.watched_media_type AS ENUM ('movie', 'show');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
""")
|
||||
|
||||
# Create tables
|
||||
await cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS moviemap.source (
|
||||
id SERIAL PRIMARY KEY,
|
||||
kind moviemap.source_kind NOT NULL UNIQUE,
|
||||
base_url TEXT NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
last_sync_at TIMESTAMPTZ
|
||||
)
|
||||
""")
|
||||
|
||||
await cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS moviemap.media_item (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
source_kind moviemap.source_kind NOT NULL,
|
||||
source_item_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
year INTEGER,
|
||||
media_type moviemap.media_type NOT NULL,
|
||||
arr_raw JSONB,
|
||||
UNIQUE (source_kind, source_item_id)
|
||||
)
|
||||
""")
|
||||
|
||||
await cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS moviemap.media_country (
|
||||
media_item_id UUID NOT NULL REFERENCES moviemap.media_item(id) ON DELETE CASCADE,
|
||||
country_code CHAR(2) NOT NULL,
|
||||
weight SMALLINT NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (media_item_id, country_code)
|
||||
)
|
||||
""")
|
||||
|
||||
await cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS moviemap.watched_item (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
media_type moviemap.watched_media_type NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
year INTEGER,
|
||||
country_code CHAR(2) NOT NULL,
|
||||
watched_at TIMESTAMPTZ,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
|
||||
await cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS moviemap.manual_pin (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
country_code CHAR(2) NOT NULL,
|
||||
label TEXT,
|
||||
pinned_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
|
||||
await conn.commit()
|
||||
|
||||
yield pool
|
||||
|
||||
# Cleanup: drop all data but keep schema
|
||||
async with pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute("TRUNCATE TABLE moviemap.media_country CASCADE")
|
||||
await cur.execute("TRUNCATE TABLE moviemap.media_item CASCADE")
|
||||
await cur.execute("TRUNCATE TABLE moviemap.watched_item CASCADE")
|
||||
await cur.execute("TRUNCATE TABLE moviemap.manual_pin CASCADE")
|
||||
await cur.execute("TRUNCATE TABLE moviemap.source CASCADE")
|
||||
await conn.commit()
|
||||
|
||||
await pool.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
async def test_client(test_db_pool) -> AsyncGenerator[AsyncClient, None]:
|
||||
"""Create a test client with test database"""
|
||||
# Temporarily replace the global pool
|
||||
import app.core.database
|
||||
original_pool = app.core.database.pool
|
||||
app.core.database.pool = test_db_pool
|
||||
|
||||
async with AsyncClient(app=app, base_url="http://test") as client:
|
||||
yield client
|
||||
|
||||
# Restore original pool
|
||||
app.core.database.pool = original_pool
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_radarr_response():
|
||||
"""Mock Radarr API response"""
|
||||
return [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Test Movie",
|
||||
"year": 2020,
|
||||
"tmdbId": 12345,
|
||||
"productionCountries": [{"iso_3166_1": "US"}]
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_sonarr_response():
|
||||
"""Mock Sonarr API response"""
|
||||
return [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Test Show",
|
||||
"year": 2020,
|
||||
"tmdbId": 67890,
|
||||
"seriesMetadata": {"originCountry": ["US"]}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_lidarr_response():
|
||||
"""Mock Lidarr API response"""
|
||||
return [
|
||||
{
|
||||
"id": 1,
|
||||
"artistName": "Test Artist",
|
||||
"country": "US",
|
||||
"foreignArtistId": "test-mbid-123"
|
||||
}
|
||||
]
|
||||
|
||||
261
backend/tests/test_api.py
Normal file
261
backend/tests/test_api.py
Normal file
@@ -0,0 +1,261 @@
|
||||
"""Tests for API endpoints"""
|
||||
import pytest
|
||||
from uuid import uuid4
|
||||
import json
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_endpoint(test_client):
|
||||
"""Test health check endpoint"""
|
||||
response = await test_client.get("/api/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collection_summary_empty(test_client, test_db_pool):
|
||||
"""Test collection summary with no data"""
|
||||
response = await test_client.get("/api/collection/summary")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collection_summary_with_data(test_client, test_db_pool):
|
||||
"""Test collection summary with media items"""
|
||||
# Insert test data
|
||||
async with test_db_pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
# Insert media item
|
||||
await cur.execute("""
|
||||
INSERT INTO moviemap.media_item
|
||||
(source_kind, source_item_id, title, year, media_type, arr_raw)
|
||||
VALUES ('radarr', 1, 'Test Movie', 2020, 'movie', '{}'::jsonb)
|
||||
RETURNING id
|
||||
""")
|
||||
media_id = (await cur.fetchone())[0]
|
||||
|
||||
# Insert country association
|
||||
await cur.execute("""
|
||||
INSERT INTO moviemap.media_country (media_item_id, country_code)
|
||||
VALUES (%s, 'US')
|
||||
""", (media_id,))
|
||||
|
||||
await conn.commit()
|
||||
|
||||
response = await test_client.get("/api/collection/summary")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "US" in data
|
||||
assert data["US"]["movie"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collection_summary_filtered(test_client, test_db_pool):
|
||||
"""Test collection summary with type filters"""
|
||||
# Insert test data
|
||||
async with test_db_pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
# Insert movie
|
||||
await cur.execute("""
|
||||
INSERT INTO moviemap.media_item
|
||||
(source_kind, source_item_id, title, year, media_type, arr_raw)
|
||||
VALUES ('radarr', 1, 'Test Movie', 2020, 'movie', '{}'::jsonb)
|
||||
RETURNING id
|
||||
""")
|
||||
movie_id = (await cur.fetchone())[0]
|
||||
await cur.execute("""
|
||||
INSERT INTO moviemap.media_country (media_item_id, country_code)
|
||||
VALUES (%s, 'US')
|
||||
""", (movie_id,))
|
||||
|
||||
# Insert show
|
||||
await cur.execute("""
|
||||
INSERT INTO moviemap.media_item
|
||||
(source_kind, source_item_id, title, year, media_type, arr_raw)
|
||||
VALUES ('sonarr', 1, 'Test Show', 2020, 'show', '{}'::jsonb)
|
||||
RETURNING id
|
||||
""")
|
||||
show_id = (await cur.fetchone())[0]
|
||||
await cur.execute("""
|
||||
INSERT INTO moviemap.media_country (media_item_id, country_code)
|
||||
VALUES (%s, 'US')
|
||||
""", (show_id,))
|
||||
|
||||
await conn.commit()
|
||||
|
||||
# Test with movie filter
|
||||
response = await test_client.get("/api/collection/summary?types=movie")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "US" in data
|
||||
assert data["US"]["movie"] == 1
|
||||
assert "show" not in data["US"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_watched_list_empty(test_client, test_db_pool):
|
||||
"""Test watched items list with no data"""
|
||||
response = await test_client.get("/api/watched")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_watched_create(test_client, test_db_pool):
|
||||
"""Test creating a watched item"""
|
||||
response = await test_client.post(
|
||||
"/api/watched",
|
||||
json={
|
||||
"media_type": "movie",
|
||||
"title": "Test Movie",
|
||||
"year": 2020,
|
||||
"country_code": "US",
|
||||
"notes": "Test notes"
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "id" in data
|
||||
assert data["status"] == "created"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_watched_list_with_data(test_client, test_db_pool):
|
||||
"""Test watched items list with data"""
|
||||
# Create watched item
|
||||
create_response = await test_client.post(
|
||||
"/api/watched",
|
||||
json={
|
||||
"media_type": "movie",
|
||||
"title": "Test Movie",
|
||||
"country_code": "US"
|
||||
}
|
||||
)
|
||||
assert create_response.status_code == 200
|
||||
|
||||
# List watched items
|
||||
response = await test_client.get("/api/watched")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["title"] == "Test Movie"
|
||||
assert data[0]["country_code"] == "US"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_watched_delete(test_client, test_db_pool):
|
||||
"""Test deleting a watched item"""
|
||||
# Create watched item
|
||||
create_response = await test_client.post(
|
||||
"/api/watched",
|
||||
json={
|
||||
"media_type": "movie",
|
||||
"title": "Test Movie",
|
||||
"country_code": "US"
|
||||
}
|
||||
)
|
||||
item_id = create_response.json()["id"]
|
||||
|
||||
# Delete it
|
||||
response = await test_client.delete(f"/api/watched/{item_id}")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "deleted"
|
||||
|
||||
# Verify it's gone
|
||||
list_response = await test_client.get("/api/watched")
|
||||
assert len(list_response.json()) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_watched_summary(test_client, test_db_pool):
|
||||
"""Test watched items summary"""
|
||||
# Create watched items
|
||||
await test_client.post(
|
||||
"/api/watched",
|
||||
json={
|
||||
"media_type": "movie",
|
||||
"title": "Test Movie",
|
||||
"country_code": "US",
|
||||
"watched_at": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
)
|
||||
await test_client.post(
|
||||
"/api/watched",
|
||||
json={
|
||||
"media_type": "show",
|
||||
"title": "Test Show",
|
||||
"country_code": "US",
|
||||
"watched_at": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
)
|
||||
|
||||
response = await test_client.get("/api/watched/summary")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "US" in data
|
||||
assert data["US"]["movie"] == 1
|
||||
assert data["US"]["show"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pins_list_empty(test_client, test_db_pool):
|
||||
"""Test pins list with no data"""
|
||||
response = await test_client.get("/api/pins")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pins_create(test_client, test_db_pool):
|
||||
"""Test creating a pin"""
|
||||
response = await test_client.post(
|
||||
"/api/pins",
|
||||
json={
|
||||
"country_code": "US",
|
||||
"label": "Test Pin"
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "id" in data
|
||||
assert data["status"] == "created"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pins_delete(test_client, test_db_pool):
|
||||
"""Test deleting a pin"""
|
||||
# Create pin
|
||||
create_response = await test_client.post(
|
||||
"/api/pins",
|
||||
json={"country_code": "US"}
|
||||
)
|
||||
pin_id = create_response.json()["id"]
|
||||
|
||||
# Delete it
|
||||
response = await test_client.delete(f"/api/pins/{pin_id}")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "deleted"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_sync_no_auth(test_client, test_db_pool):
|
||||
"""Test admin sync without auth (should work if no token configured)"""
|
||||
# This will fail if admin token is required, but should work if not
|
||||
# We'll mock the sync function to avoid actual API calls
|
||||
response = await test_client.post("/admin/sync")
|
||||
# Either 200 (no auth required) or 401 (auth required)
|
||||
assert response.status_code in [200, 401, 500] # 500 if sync fails due to missing *arr
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_countries_empty(test_client, test_db_pool):
|
||||
"""Test missing countries endpoint with no data"""
|
||||
response = await test_client.get("/admin/missing-countries")
|
||||
# May require auth, but if not, should return empty
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
assert data["total"] == 0
|
||||
assert data["returned"] == 0
|
||||
assert data["items"] == []
|
||||
|
||||
153
backend/tests/test_database.py
Normal file
153
backend/tests/test_database.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""Tests for database operations"""
|
||||
import pytest
|
||||
import json
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_media_item_insert(test_db_pool):
|
||||
"""Test inserting a media item"""
|
||||
async with test_db_pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute("""
|
||||
INSERT INTO moviemap.media_item
|
||||
(source_kind, source_item_id, title, year, media_type, arr_raw)
|
||||
VALUES ('radarr', 1, 'Test Movie', 2020, 'movie', %s::jsonb)
|
||||
RETURNING id, title
|
||||
""", (json.dumps({"test": "data"}),))
|
||||
|
||||
result = await cur.fetchone()
|
||||
assert result is not None
|
||||
assert result[1] == "Test Movie"
|
||||
|
||||
await conn.commit()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_media_country_association(test_db_pool):
|
||||
"""Test associating a country with a media item"""
|
||||
async with test_db_pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
# Insert media item
|
||||
await cur.execute("""
|
||||
INSERT INTO moviemap.media_item
|
||||
(source_kind, source_item_id, title, year, media_type, arr_raw)
|
||||
VALUES ('radarr', 1, 'Test Movie', 2020, 'movie', '{}'::jsonb)
|
||||
RETURNING id
|
||||
""")
|
||||
media_id = (await cur.fetchone())[0]
|
||||
|
||||
# Associate country
|
||||
await cur.execute("""
|
||||
INSERT INTO moviemap.media_country (media_item_id, country_code)
|
||||
VALUES (%s, 'US')
|
||||
""", (media_id,))
|
||||
|
||||
# Verify association
|
||||
await cur.execute("""
|
||||
SELECT country_code FROM moviemap.media_country
|
||||
WHERE media_item_id = %s
|
||||
""", (media_id,))
|
||||
|
||||
result = await cur.fetchone()
|
||||
assert result is not None
|
||||
assert result[0] == "US"
|
||||
|
||||
await conn.commit()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_media_item_unique_constraint(test_db_pool):
|
||||
"""Test that source_kind + source_item_id is unique"""
|
||||
async with test_db_pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
# Insert first item
|
||||
await cur.execute("""
|
||||
INSERT INTO moviemap.media_item
|
||||
(source_kind, source_item_id, title, year, media_type, arr_raw)
|
||||
VALUES ('radarr', 1, 'Test Movie', 2020, 'movie', '{}'::jsonb)
|
||||
""")
|
||||
|
||||
# Try to insert duplicate
|
||||
with pytest.raises(Exception): # Should raise unique constraint violation
|
||||
await cur.execute("""
|
||||
INSERT INTO moviemap.media_item
|
||||
(source_kind, source_item_id, title, year, media_type, arr_raw)
|
||||
VALUES ('radarr', 1, 'Another Movie', 2021, 'movie', '{}'::jsonb)
|
||||
""")
|
||||
|
||||
await conn.rollback()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_watched_item_insert(test_db_pool):
|
||||
"""Test inserting a watched item"""
|
||||
async with test_db_pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute("""
|
||||
INSERT INTO moviemap.watched_item
|
||||
(media_type, title, year, country_code, notes)
|
||||
VALUES ('movie', 'Test Movie', 2020, 'US', 'Test notes')
|
||||
RETURNING id, title
|
||||
""")
|
||||
|
||||
result = await cur.fetchone()
|
||||
assert result is not None
|
||||
assert result[1] == "Test Movie"
|
||||
|
||||
await conn.commit()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manual_pin_insert(test_db_pool):
|
||||
"""Test inserting a manual pin"""
|
||||
async with test_db_pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute("""
|
||||
INSERT INTO moviemap.manual_pin (country_code, label)
|
||||
VALUES ('US', 'Test Pin')
|
||||
RETURNING id, country_code
|
||||
""")
|
||||
|
||||
result = await cur.fetchone()
|
||||
assert result is not None
|
||||
assert result[1] == "US"
|
||||
|
||||
await conn.commit()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cascade_delete(test_db_pool):
|
||||
"""Test that deleting a media item cascades to country associations"""
|
||||
async with test_db_pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
# Insert media item with country
|
||||
await cur.execute("""
|
||||
INSERT INTO moviemap.media_item
|
||||
(source_kind, source_item_id, title, year, media_type, arr_raw)
|
||||
VALUES ('radarr', 1, 'Test Movie', 2020, 'movie', '{}'::jsonb)
|
||||
RETURNING id
|
||||
""")
|
||||
media_id = (await cur.fetchone())[0]
|
||||
|
||||
await cur.execute("""
|
||||
INSERT INTO moviemap.media_country (media_item_id, country_code)
|
||||
VALUES (%s, 'US')
|
||||
""", (media_id,))
|
||||
|
||||
# Delete media item
|
||||
await cur.execute("""
|
||||
DELETE FROM moviemap.media_item WHERE id = %s
|
||||
""", (media_id,))
|
||||
|
||||
# Verify country association is also deleted
|
||||
await cur.execute("""
|
||||
SELECT COUNT(*) FROM moviemap.media_country
|
||||
WHERE media_item_id = %s
|
||||
""", (media_id,))
|
||||
|
||||
count = (await cur.fetchone())[0]
|
||||
assert count == 0
|
||||
|
||||
await conn.commit()
|
||||
|
||||
204
backend/tests/test_sync.py
Normal file
204
backend/tests/test_sync.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""Tests for sync service"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from app.services.sync import (
|
||||
extract_country_from_radarr,
|
||||
extract_country_from_sonarr,
|
||||
extract_country_from_lidarr,
|
||||
upsert_media_item,
|
||||
)
|
||||
|
||||
|
||||
def test_extract_country_from_radarr_with_production_countries():
|
||||
"""Test extracting country from Radarr movie with productionCountries"""
|
||||
movie = {
|
||||
"id": 1,
|
||||
"title": "Test Movie",
|
||||
"productionCountries": [{"iso_3166_1": "US"}]
|
||||
}
|
||||
country = extract_country_from_radarr(movie)
|
||||
assert country == "US"
|
||||
|
||||
|
||||
def test_extract_country_from_radarr_with_metadata():
|
||||
"""Test extracting country from Radarr movie with movieMetadata"""
|
||||
movie = {
|
||||
"id": 1,
|
||||
"title": "Test Movie",
|
||||
"movieMetadata": {
|
||||
"productionCountries": [{"iso_3166_1": "GB"}]
|
||||
}
|
||||
}
|
||||
country = extract_country_from_radarr(movie)
|
||||
assert country == "GB"
|
||||
|
||||
|
||||
def test_extract_country_from_radarr_no_country():
|
||||
"""Test extracting country from Radarr movie with no country"""
|
||||
movie = {
|
||||
"id": 1,
|
||||
"title": "Test Movie"
|
||||
}
|
||||
country = extract_country_from_radarr(movie)
|
||||
assert country is None
|
||||
|
||||
|
||||
def test_extract_country_from_sonarr_with_metadata():
|
||||
"""Test extracting country from Sonarr series with seriesMetadata"""
|
||||
series = {
|
||||
"id": 1,
|
||||
"title": "Test Show",
|
||||
"seriesMetadata": {
|
||||
"originCountry": ["US"]
|
||||
}
|
||||
}
|
||||
country = extract_country_from_sonarr(series)
|
||||
assert country == "US"
|
||||
|
||||
|
||||
def test_extract_country_from_sonarr_string_country():
|
||||
"""Test extracting country from Sonarr series with string country"""
|
||||
series = {
|
||||
"id": 1,
|
||||
"title": "Test Show",
|
||||
"seriesMetadata": {
|
||||
"originCountry": "US"
|
||||
}
|
||||
}
|
||||
country = extract_country_from_sonarr(series)
|
||||
assert country == "US"
|
||||
|
||||
|
||||
def test_extract_country_from_lidarr_with_country():
|
||||
"""Test extracting country from Lidarr artist with country field"""
|
||||
artist = {
|
||||
"id": 1,
|
||||
"artistName": "Test Artist",
|
||||
"country": "US"
|
||||
}
|
||||
country = extract_country_from_lidarr(artist)
|
||||
assert country == "US"
|
||||
|
||||
|
||||
def test_extract_country_from_lidarr_no_country():
|
||||
"""Test extracting country from Lidarr artist with no country"""
|
||||
artist = {
|
||||
"id": 1,
|
||||
"artistName": "Test Artist"
|
||||
}
|
||||
country = extract_country_from_lidarr(artist)
|
||||
assert country is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_media_item_new(test_db_pool):
|
||||
"""Test upserting a new media item"""
|
||||
# Temporarily replace the global pool
|
||||
import app.core.database
|
||||
original_pool = app.core.database.pool
|
||||
app.core.database.pool = test_db_pool
|
||||
|
||||
try:
|
||||
media_id = await upsert_media_item(
|
||||
source_kind="radarr",
|
||||
source_item_id=1,
|
||||
title="Test Movie",
|
||||
year=2020,
|
||||
media_type="movie",
|
||||
arr_raw={"id": 1, "title": "Test Movie", "productionCountries": [{"iso_3166_1": "US"}]}
|
||||
)
|
||||
|
||||
assert media_id is not None
|
||||
|
||||
# Verify it was inserted
|
||||
async with test_db_pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute("""
|
||||
SELECT title, year, media_type FROM moviemap.media_item
|
||||
WHERE id = %s
|
||||
""", (media_id,))
|
||||
result = await cur.fetchone()
|
||||
assert result is not None
|
||||
assert result[0] == "Test Movie"
|
||||
assert result[1] == 2020
|
||||
assert result[2] == "movie"
|
||||
finally:
|
||||
app.core.database.pool = original_pool
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_media_item_with_country(test_db_pool):
|
||||
"""Test upserting a media item with country association"""
|
||||
# Temporarily replace the global pool
|
||||
import app.core.database
|
||||
original_pool = app.core.database.pool
|
||||
app.core.database.pool = test_db_pool
|
||||
|
||||
try:
|
||||
media_id = await upsert_media_item(
|
||||
source_kind="radarr",
|
||||
source_item_id=1,
|
||||
title="Test Movie",
|
||||
year=2020,
|
||||
media_type="movie",
|
||||
arr_raw={"id": 1, "title": "Test Movie", "productionCountries": [{"iso_3166_1": "US"}]}
|
||||
)
|
||||
|
||||
# Verify country was associated
|
||||
async with test_db_pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute("""
|
||||
SELECT country_code FROM moviemap.media_country
|
||||
WHERE media_item_id = %s
|
||||
""", (media_id,))
|
||||
result = await cur.fetchone()
|
||||
assert result is not None
|
||||
assert result[0] == "US"
|
||||
finally:
|
||||
app.core.database.pool = original_pool
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_media_item_update(test_db_pool):
|
||||
"""Test updating an existing media item"""
|
||||
# Temporarily replace the global pool
|
||||
import app.core.database
|
||||
original_pool = app.core.database.pool
|
||||
app.core.database.pool = test_db_pool
|
||||
|
||||
try:
|
||||
# Insert first
|
||||
media_id = await upsert_media_item(
|
||||
source_kind="radarr",
|
||||
source_item_id=1,
|
||||
title="Test Movie",
|
||||
year=2020,
|
||||
media_type="movie",
|
||||
arr_raw={"id": 1, "title": "Test Movie"}
|
||||
)
|
||||
|
||||
# Update with new title
|
||||
updated_id = await upsert_media_item(
|
||||
source_kind="radarr",
|
||||
source_item_id=1,
|
||||
title="Updated Movie",
|
||||
year=2021,
|
||||
media_type="movie",
|
||||
arr_raw={"id": 1, "title": "Updated Movie"}
|
||||
)
|
||||
|
||||
assert updated_id == media_id # Same ID
|
||||
|
||||
# Verify it was updated
|
||||
async with test_db_pool.connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute("""
|
||||
SELECT title, year FROM moviemap.media_item
|
||||
WHERE id = %s
|
||||
""", (media_id,))
|
||||
result = await cur.fetchone()
|
||||
assert result[0] == "Updated Movie"
|
||||
assert result[1] == 2021
|
||||
finally:
|
||||
app.core.database.pool = original_pool
|
||||
|
||||
@@ -20,7 +20,19 @@
|
||||
"@types/leaflet": "^1.9.8",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.8"
|
||||
"vite": "^5.0.8",
|
||||
"vitest": "^1.0.4",
|
||||
"@testing-library/react": "^14.1.2",
|
||||
"@testing-library/jest-dom": "^6.1.5",
|
||||
"@testing-library/user-event": "^14.5.1",
|
||||
"jsdom": "^23.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom'
|
||||
import CollectionMap from './components/CollectionMap'
|
||||
import WatchedMap from './components/WatchedMap'
|
||||
import MissingLocations from './components/MissingLocations'
|
||||
import './App.css'
|
||||
|
||||
function App() {
|
||||
@@ -12,6 +13,7 @@ function App() {
|
||||
<Routes>
|
||||
<Route path="/" element={<CollectionMap />} />
|
||||
<Route path="/watched" element={<WatchedMap />} />
|
||||
<Route path="/missing-locations" element={<MissingLocations />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</Router>
|
||||
@@ -38,6 +40,12 @@ function NavBar() {
|
||||
>
|
||||
Watched Map
|
||||
</Link>
|
||||
<Link
|
||||
to="/missing-locations"
|
||||
className={location.pathname === '/missing-locations' ? 'active' : ''}
|
||||
>
|
||||
Missing Locations
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
79
frontend/src/components/AutocompleteInput.css
Normal file
79
frontend/src/components/AutocompleteInput.css
Normal file
@@ -0,0 +1,79 @@
|
||||
.autocomplete-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.autocomplete-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.autocomplete-input:focus {
|
||||
outline: none;
|
||||
border-color: #4a90e2;
|
||||
box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.2);
|
||||
}
|
||||
|
||||
.autocomplete-loading {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 8px;
|
||||
background: white;
|
||||
border: 1px solid #ccc;
|
||||
border-top: none;
|
||||
border-radius: 0 0 4px 4px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.autocomplete-suggestions {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
background: white;
|
||||
border: 1px solid #ccc;
|
||||
border-top: none;
|
||||
border-radius: 0 0 4px 4px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.autocomplete-suggestion {
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.autocomplete-suggestion:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.autocomplete-suggestion:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.suggestion-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.suggestion-year {
|
||||
color: #666;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.suggestion-overview {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
113
frontend/src/components/AutocompleteInput.tsx
Normal file
113
frontend/src/components/AutocompleteInput.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { searchTMDB, TMDBResult } from '../utils/tmdb'
|
||||
import './AutocompleteInput.css'
|
||||
|
||||
interface AutocompleteInputProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSelect: (result: TMDBResult) => void
|
||||
type: 'movie' | 'tv'
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function AutocompleteInput({
|
||||
value,
|
||||
onChange,
|
||||
onSelect,
|
||||
type,
|
||||
placeholder = 'Search...',
|
||||
disabled = false,
|
||||
}: AutocompleteInputProps) {
|
||||
const [suggestions, setSuggestions] = useState<TMDBResult[]>([])
|
||||
const [showSuggestions, setShowSuggestions] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const suggestionsRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (value.trim().length >= 2) {
|
||||
performSearch(value)
|
||||
} else {
|
||||
setSuggestions([])
|
||||
setShowSuggestions(false)
|
||||
}
|
||||
}, 300) // Debounce 300ms
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [value, type])
|
||||
|
||||
const performSearch = async (query: string) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const results = await searchTMDB(query, type)
|
||||
setSuggestions(results)
|
||||
setShowSuggestions(results.length > 0)
|
||||
} catch (error) {
|
||||
console.error('Search error:', error)
|
||||
setSuggestions([])
|
||||
setShowSuggestions(false)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelect = (result: TMDBResult) => {
|
||||
onChange(result.title)
|
||||
onSelect(result)
|
||||
setShowSuggestions(false)
|
||||
inputRef.current?.blur()
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
// Delay hiding suggestions to allow click events
|
||||
setTimeout(() => {
|
||||
setShowSuggestions(false)
|
||||
}, 200)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="autocomplete-container">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onFocus={() => {
|
||||
if (suggestions.length > 0) {
|
||||
setShowSuggestions(true)
|
||||
}
|
||||
}}
|
||||
onBlur={handleBlur}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className="autocomplete-input"
|
||||
/>
|
||||
{loading && <div className="autocomplete-loading">Searching...</div>}
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<div ref={suggestionsRef} className="autocomplete-suggestions">
|
||||
{suggestions.map((result) => (
|
||||
<div
|
||||
key={`${result.type}-${result.id}`}
|
||||
className="autocomplete-suggestion"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
handleSelect(result)
|
||||
}}
|
||||
>
|
||||
<div className="suggestion-title">
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { MapContainer, TileLayer, GeoJSON, CircleMarker, Popup } from 'react-leaflet'
|
||||
import { LatLngExpression } from 'leaflet'
|
||||
import { MapContainer, TileLayer, GeoJSON } from 'react-leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import './Map.css'
|
||||
import { formatCountryDisplay, getCountryInfo } from '../utils/countries'
|
||||
import CountryMediaList from './CountryMediaList'
|
||||
|
||||
interface CountryData {
|
||||
[countryCode: string]: {
|
||||
@@ -27,6 +28,7 @@ export default function CollectionMap() {
|
||||
})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [worldGeoJson, setWorldGeoJson] = useState<any>(null)
|
||||
const [selectedCountry, setSelectedCountry] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Load world countries GeoJSON
|
||||
@@ -78,36 +80,29 @@ export default function CollectionMap() {
|
||||
if (count === 0) return '#e0e0e0'
|
||||
|
||||
const intensity = count / maxCount
|
||||
// Blue gradient: light blue to dark blue
|
||||
const r = Math.floor(74 + (180 - 74) * (1 - intensity))
|
||||
const g = Math.floor(144 + (200 - 144) * (1 - intensity))
|
||||
const b = Math.floor(226 + (255 - 226) * (1 - intensity))
|
||||
// Enhanced gradient: light blue to vibrant dark blue
|
||||
// More vibrant colors for better visibility
|
||||
const r = Math.floor(100 + (30 - 100) * intensity)
|
||||
const g = Math.floor(150 + (60 - 150) * intensity)
|
||||
const b = Math.floor(255 + (200 - 255) * intensity)
|
||||
return `rgb(${r}, ${g}, ${b})`
|
||||
}
|
||||
|
||||
const getCountryCenter = (countryCode: string): LatLngExpression | null => {
|
||||
// Simplified country centers - in production, use a proper lookup
|
||||
const centers: { [key: string]: LatLngExpression } = {
|
||||
'US': [39.8283, -98.5795],
|
||||
'GB': [55.3781, -3.4360],
|
||||
'FR': [46.2276, 2.2137],
|
||||
'DE': [51.1657, 10.4515],
|
||||
'JP': [36.2048, 138.2529],
|
||||
'CN': [35.8617, 104.1954],
|
||||
'IN': [20.5937, 78.9629],
|
||||
'KR': [35.9078, 127.7669],
|
||||
'TH': [15.8700, 100.9925],
|
||||
'MX': [23.6345, -102.5528],
|
||||
'BR': [-14.2350, -51.9253],
|
||||
'CA': [56.1304, -106.3468],
|
||||
'AU': [-25.2744, 133.7751],
|
||||
'IT': [41.8719, 12.5674],
|
||||
'ES': [40.4637, -3.7492],
|
||||
'RU': [61.5240, 105.3188],
|
||||
const handleCountryClick = (countryCode: string) => {
|
||||
setSelectedCountry(countryCode)
|
||||
}
|
||||
return centers[countryCode] || null
|
||||
|
||||
useEffect(() => {
|
||||
const handleShowCountryMedia = (event: CustomEvent) => {
|
||||
setSelectedCountry(event.detail.countryCode)
|
||||
}
|
||||
|
||||
window.addEventListener('showCountryMedia', handleShowCountryMedia as EventListener)
|
||||
return () => {
|
||||
window.removeEventListener('showCountryMedia', handleShowCountryMedia as EventListener)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const toggleFilter = (type: keyof MediaTypeFilter) => {
|
||||
setFilters(prev => ({ ...prev, [type]: !prev[type] }))
|
||||
}
|
||||
@@ -176,41 +171,51 @@ export default function CollectionMap() {
|
||||
const count = getCountryCount(code)
|
||||
const data = countryData[code] || {}
|
||||
|
||||
layer.bindPopup(`
|
||||
<strong>${feature.properties.NAME || code}</strong><br/>
|
||||
Total: ${count}<br/>
|
||||
${data.movie ? `Movies: ${data.movie}<br/>` : ''}
|
||||
${data.show ? `Shows: ${data.show}<br/>` : ''}
|
||||
${data.music ? `Music: ${data.music}` : ''}
|
||||
`)
|
||||
if (count === 0) return
|
||||
|
||||
const countryInfo = getCountryInfo(code)
|
||||
const displayName = formatCountryDisplay(code)
|
||||
|
||||
const popupContent = `
|
||||
<div style="cursor: pointer; text-align: center;">
|
||||
<strong style="font-size: 16px;">${displayName}</strong><br/>
|
||||
<div style="margin-top: 8px;">
|
||||
<strong>Count: ${count}</strong><br/>
|
||||
${data.movie ? `<span>Movies: ${data.movie}</span><br/>` : ''}
|
||||
${data.show ? `<span>Shows: ${data.show}</span><br/>` : ''}
|
||||
${data.music ? `<span>Music: ${data.music}</span>` : ''}
|
||||
</div>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: #666;">
|
||||
Click to view media
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
layer.bindPopup(popupContent)
|
||||
|
||||
// Make popup clickable
|
||||
layer.on('popupopen', () => {
|
||||
const popup = layer.getPopup()
|
||||
if (popup) {
|
||||
const popupElement = popup.getElement()
|
||||
if (popupElement) {
|
||||
popupElement.style.cursor = 'pointer'
|
||||
popupElement.addEventListener('click', () => {
|
||||
handleCountryClick(code)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{Object.keys(countryData).map(code => {
|
||||
const count = getCountryCount(code)
|
||||
if (count === 0) return null
|
||||
|
||||
const center = getCountryCenter(code)
|
||||
if (!center) return null
|
||||
|
||||
return (
|
||||
<CircleMarker
|
||||
key={code}
|
||||
center={center}
|
||||
radius={Math.max(8, Math.min(30, count / 2))}
|
||||
fillColor="#ff6b6b"
|
||||
fillOpacity={0.8}
|
||||
color="#fff"
|
||||
weight={2}
|
||||
>
|
||||
<Popup>
|
||||
<strong>{code}</strong><br/>
|
||||
Count: {count}
|
||||
</Popup>
|
||||
</CircleMarker>
|
||||
)
|
||||
})}
|
||||
</MapContainer>
|
||||
{selectedCountry && (
|
||||
<CountryMediaList
|
||||
countryCode={selectedCountry}
|
||||
onClose={() => setSelectedCountry(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
130
frontend/src/components/CountryMediaList.css
Normal file
130
frontend/src/components/CountryMediaList.css
Normal file
@@ -0,0 +1,130 @@
|
||||
.country-media-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.country-media-modal {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.country-media-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.country-media-header h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 32px;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.country-media-content {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.media-count {
|
||||
margin-bottom: 16px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.media-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.media-item {
|
||||
padding: 12px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.media-item-title {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.media-item-title strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.media-year {
|
||||
color: #666;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.media-item-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.media-type {
|
||||
padding: 2px 8px;
|
||||
background: #e3f2fd;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.media-source {
|
||||
padding: 2px 8px;
|
||||
background: #f3e5f5;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.no-items {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.loading, .error {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
106
frontend/src/components/CountryMediaList.tsx
Normal file
106
frontend/src/components/CountryMediaList.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { formatCountryDisplay } from '../utils/countries'
|
||||
import './CountryMediaList.css'
|
||||
|
||||
interface MediaItem {
|
||||
id: string
|
||||
source_kind: string
|
||||
source_item_id: number
|
||||
title: string
|
||||
year: number | null
|
||||
media_type: 'movie' | 'show' | 'music'
|
||||
}
|
||||
|
||||
interface CountryMediaListProps {
|
||||
countryCode: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function CountryMediaList({ countryCode, onClose }: CountryMediaListProps) {
|
||||
const [mediaItems, setMediaItems] = useState<MediaItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchMediaItems()
|
||||
}, [countryCode])
|
||||
|
||||
const fetchMediaItems = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await fetch(`/api/collection/by-country?country_code=${countryCode}`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch media items')
|
||||
}
|
||||
const data = await response.json()
|
||||
setMediaItems(data.items || [])
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getMediaTypeLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case 'movie': return 'Movie'
|
||||
case 'show': return 'TV Show'
|
||||
case 'music': return 'Music'
|
||||
default: return type
|
||||
}
|
||||
}
|
||||
|
||||
const getSourceLabel = (source: string) => {
|
||||
switch (source) {
|
||||
case 'radarr': return 'Radarr'
|
||||
case 'sonarr': return 'Sonarr'
|
||||
case 'lidarr': return 'Lidarr'
|
||||
default: return source
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="country-media-modal-overlay" onClick={onClose}>
|
||||
<div className="country-media-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="country-media-header">
|
||||
<h2>{formatCountryDisplay(countryCode)}</h2>
|
||||
<button className="close-button" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
<div className="country-media-content">
|
||||
{loading && <div className="loading">Loading media items...</div>}
|
||||
{error && <div className="error">Error: {error}</div>}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<div className="media-count">
|
||||
{mediaItems.length} {mediaItems.length === 1 ? 'item' : 'items'}
|
||||
</div>
|
||||
|
||||
{mediaItems.length === 0 ? (
|
||||
<div className="no-items">No media items found for this country.</div>
|
||||
) : (
|
||||
<div className="media-list">
|
||||
{mediaItems.map((item) => (
|
||||
<div key={item.id} className="media-item">
|
||||
<div className="media-item-title">
|
||||
<strong>{item.title}</strong>
|
||||
{item.year && <span className="media-year"> ({item.year})</span>}
|
||||
</div>
|
||||
<div className="media-item-meta">
|
||||
<span className="media-type">{getMediaTypeLabel(item.media_type)}</span>
|
||||
<span className="media-source">{getSourceLabel(item.source_kind)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -107,27 +107,81 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ddd;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.watched-item button {
|
||||
.watched-item-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.watched-item-year {
|
||||
color: #666;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.watched-item-country {
|
||||
color: #999;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.25rem 0.75rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.delete-button:hover {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
.delete-confirm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.confirm-button {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.watched-item button:hover {
|
||||
.confirm-button:hover {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
background: #95a5a6;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.cancel-button:hover {
|
||||
background: #7f8c8d;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
184
frontend/src/components/MissingLocations.css
Normal file
184
frontend/src/components/MissingLocations.css
Normal file
@@ -0,0 +1,184 @@
|
||||
.missing-locations-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.missing-locations-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.missing-locations-header h1 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.missing-locations-filters {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.filter-group select {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.missing-locations-content {
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.missing-locations-summary {
|
||||
margin-bottom: 1rem;
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.media-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.media-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.media-table thead {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.media-table th {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
border-bottom: 2px solid #ddd;
|
||||
}
|
||||
|
||||
.media-table td {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.title-cell {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.type-badge,
|
||||
.source-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.type-badge.type-movie {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.type-badge.type-show {
|
||||
background: #f3e5f5;
|
||||
color: #7b1fa2;
|
||||
}
|
||||
|
||||
.type-badge.type-music {
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
.source-badge.source-radarr {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.source-badge.source-sonarr {
|
||||
background: #e1f5fe;
|
||||
color: #0277bd;
|
||||
}
|
||||
|
||||
.source-badge.source-lidarr {
|
||||
background: #fce4ec;
|
||||
color: #c2185b;
|
||||
}
|
||||
|
||||
.no-items {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.subtext {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.page-button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.page-button:hover:not(:disabled) {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.page-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
212
frontend/src/components/MissingLocations.tsx
Normal file
212
frontend/src/components/MissingLocations.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import './MissingLocations.css'
|
||||
|
||||
interface MediaItem {
|
||||
id: string
|
||||
source_kind: string
|
||||
source_item_id: number
|
||||
title: string
|
||||
year: number | null
|
||||
media_type: 'movie' | 'show' | 'music'
|
||||
}
|
||||
|
||||
interface MissingLocationsResponse {
|
||||
total: number
|
||||
returned: number
|
||||
offset: number
|
||||
limit: number
|
||||
items: MediaItem[]
|
||||
}
|
||||
|
||||
export default function MissingLocations() {
|
||||
const [mediaItems, setMediaItems] = useState<MediaItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [limit] = useState(50)
|
||||
const [sourceFilter, setSourceFilter] = useState<string>('')
|
||||
const [typeFilter, setTypeFilter] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
fetchMissingLocations()
|
||||
}, [offset, sourceFilter, typeFilter])
|
||||
|
||||
const fetchMissingLocations = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
limit: limit.toString(),
|
||||
offset: offset.toString(),
|
||||
})
|
||||
if (sourceFilter) params.append('source_kind', sourceFilter)
|
||||
if (typeFilter) params.append('media_type', typeFilter)
|
||||
|
||||
const response = await fetch(`/api/collection/missing-locations?${params}`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch missing locations')
|
||||
}
|
||||
const data: MissingLocationsResponse = await response.json()
|
||||
setMediaItems(data.items || [])
|
||||
setTotal(data.total || 0)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getMediaTypeLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case 'movie': return 'Movie'
|
||||
case 'show': return 'TV Show'
|
||||
case 'music': return 'Music'
|
||||
default: return type
|
||||
}
|
||||
}
|
||||
|
||||
const getSourceLabel = (source: string) => {
|
||||
switch (source) {
|
||||
case 'radarr': return 'Radarr'
|
||||
case 'sonarr': return 'Sonarr'
|
||||
case 'lidarr': return 'Lidarr'
|
||||
default: return source
|
||||
}
|
||||
}
|
||||
|
||||
const handlePrevPage = () => {
|
||||
if (offset > 0) {
|
||||
setOffset(Math.max(0, offset - limit))
|
||||
}
|
||||
}
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (offset + limit < total) {
|
||||
setOffset(offset + limit)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="missing-locations-container">
|
||||
<div className="missing-locations-header">
|
||||
<h1>Media Without Locations</h1>
|
||||
<p className="subtitle">
|
||||
Media items in your collection that don't have country information assigned.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="missing-locations-filters">
|
||||
<div className="filter-group">
|
||||
<label htmlFor="source-filter">Source:</label>
|
||||
<select
|
||||
id="source-filter"
|
||||
value={sourceFilter}
|
||||
onChange={(e) => {
|
||||
setSourceFilter(e.target.value)
|
||||
setOffset(0)
|
||||
}}
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="radarr">Radarr</option>
|
||||
<option value="sonarr">Sonarr</option>
|
||||
<option value="lidarr">Lidarr</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label htmlFor="type-filter">Type:</label>
|
||||
<select
|
||||
id="type-filter"
|
||||
value={typeFilter}
|
||||
onChange={(e) => {
|
||||
setTypeFilter(e.target.value)
|
||||
setOffset(0)
|
||||
}}
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="movie">Movie</option>
|
||||
<option value="show">TV Show</option>
|
||||
<option value="music">Music</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="missing-locations-content">
|
||||
{loading && <div className="loading">Loading...</div>}
|
||||
{error && <div className="error">Error: {error}</div>}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<div className="missing-locations-summary">
|
||||
Showing {mediaItems.length} of {total} items
|
||||
</div>
|
||||
|
||||
{mediaItems.length === 0 ? (
|
||||
<div className="no-items">
|
||||
<p>No media items without locations found.</p>
|
||||
<p className="subtext">All items in your collection have country information assigned!</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="media-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Year</th>
|
||||
<th>Type</th>
|
||||
<th>Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mediaItems.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td className="title-cell">
|
||||
<strong>{item.title}</strong>
|
||||
</td>
|
||||
<td>{item.year || '-'}</td>
|
||||
<td>
|
||||
<span className="type-badge type-{item.media_type}">
|
||||
{getMediaTypeLabel(item.media_type)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className="source-badge source-{item.source_kind}">
|
||||
{getSourceLabel(item.source_kind)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="pagination">
|
||||
<button
|
||||
onClick={handlePrevPage}
|
||||
disabled={offset === 0}
|
||||
className="page-button"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="page-info">
|
||||
Page {Math.floor(offset / limit) + 1} of {Math.ceil(total / limit) || 1}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleNextPage}
|
||||
disabled={offset + limit >= total}
|
||||
className="page-button"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { MapContainer, TileLayer, GeoJSON, CircleMarker, Popup } from 'react-lea
|
||||
import { LatLngExpression } from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import './Map.css'
|
||||
import AutocompleteInput from './AutocompleteInput'
|
||||
import { TMDBResult } from '../utils/tmdb'
|
||||
|
||||
interface WatchedItem {
|
||||
id: string
|
||||
@@ -41,6 +43,7 @@ export default function WatchedMap() {
|
||||
country_code: '',
|
||||
notes: '',
|
||||
})
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('https://raw.githubusercontent.com/holtzy/D3-graph-gallery/master/DATA/world.geojson')
|
||||
@@ -123,11 +126,20 @@ export default function WatchedMap() {
|
||||
try {
|
||||
await fetch(`/api/watched/${id}`, { method: 'DELETE' })
|
||||
fetchData()
|
||||
setDeleteConfirmId(null)
|
||||
} catch (error) {
|
||||
console.error('Failed to delete watched item:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAutocompleteSelect = (result: TMDBResult) => {
|
||||
setNewItem({
|
||||
...newItem,
|
||||
title: result.title,
|
||||
year: result.year ? result.year.toString() : '',
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeletePin = async (id: string) => {
|
||||
try {
|
||||
await fetch(`/api/pins/${id}`, { method: 'DELETE' })
|
||||
@@ -189,12 +201,12 @@ export default function WatchedMap() {
|
||||
<option value="movie">Movie</option>
|
||||
<option value="show">Show</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
<AutocompleteInput
|
||||
value={newItem.title}
|
||||
onChange={(e) => setNewItem({ ...newItem, title: e.target.value })}
|
||||
required
|
||||
onChange={(value) => setNewItem({ ...newItem, title: value })}
|
||||
onSelect={handleAutocompleteSelect}
|
||||
type={newItem.media_type}
|
||||
placeholder="Title (start typing to search)"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
@@ -225,8 +237,35 @@ export default function WatchedMap() {
|
||||
<h3>Watched Items</h3>
|
||||
{watchedItems.filter(item => item.watched_at).map(item => (
|
||||
<div key={item.id} className="watched-item">
|
||||
<strong>{item.title}</strong> ({item.country_code})
|
||||
<button onClick={() => handleDeleteWatched(item.id)}>Delete</button>
|
||||
<div className="watched-item-info">
|
||||
<strong>{item.title}</strong>
|
||||
{item.year && <span className="watched-item-year"> ({item.year})</span>}
|
||||
<span className="watched-item-country"> {item.country_code}</span>
|
||||
</div>
|
||||
{deleteConfirmId === item.id ? (
|
||||
<div className="delete-confirm">
|
||||
<span>Delete?</span>
|
||||
<button
|
||||
className="confirm-button"
|
||||
onClick={() => handleDeleteWatched(item.id)}
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
className="cancel-button"
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="delete-button"
|
||||
onClick={() => setDeleteConfirmId(item.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
70
frontend/src/components/__tests__/CollectionMap.test.tsx
Normal file
70
frontend/src/components/__tests__/CollectionMap.test.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import CollectionMap from '../CollectionMap'
|
||||
|
||||
// Mock react-leaflet
|
||||
vi.mock('react-leaflet', () => ({
|
||||
MapContainer: ({ children }: { children: React.ReactNode }) => <div data-testid="map-container">{children}</div>,
|
||||
TileLayer: () => <div data-testid="tile-layer" />,
|
||||
GeoJSON: () => <div data-testid="geojson" />,
|
||||
CircleMarker: () => null,
|
||||
Popup: () => null,
|
||||
}))
|
||||
|
||||
// Mock leaflet CSS
|
||||
vi.mock('leaflet/dist/leaflet.css', () => ({}))
|
||||
vi.mock('../Map.css', () => ({}))
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = vi.fn()
|
||||
|
||||
describe('CollectionMap', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Mock GeoJSON fetch
|
||||
;(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
type: 'FeatureCollection',
|
||||
features: []
|
||||
})
|
||||
})
|
||||
|
||||
// Mock collection summary fetch
|
||||
;(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({})
|
||||
})
|
||||
})
|
||||
|
||||
it('renders map container', async () => {
|
||||
render(<CollectionMap />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('map-container')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('displays filters', async () => {
|
||||
render(<CollectionMap />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Collection Map')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Movies')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Shows')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Music')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('fetches collection data on mount', async () => {
|
||||
render(<CollectionMap />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/collection/summary')
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
68
frontend/src/components/__tests__/WatchedMap.test.tsx
Normal file
68
frontend/src/components/__tests__/WatchedMap.test.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import WatchedMap from '../WatchedMap'
|
||||
|
||||
// Mock react-leaflet
|
||||
vi.mock('react-leaflet', () => ({
|
||||
MapContainer: ({ children }: { children: React.ReactNode }) => <div data-testid="map-container">{children}</div>,
|
||||
TileLayer: () => <div data-testid="tile-layer" />,
|
||||
GeoJSON: () => <div data-testid="geojson" />,
|
||||
CircleMarker: () => null,
|
||||
Popup: () => null,
|
||||
}))
|
||||
|
||||
// Mock leaflet CSS
|
||||
vi.mock('leaflet/dist/leaflet.css', () => ({}))
|
||||
vi.mock('../Map.css', () => ({}))
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = vi.fn()
|
||||
|
||||
describe('WatchedMap', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Mock GeoJSON fetch
|
||||
;(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
type: 'FeatureCollection',
|
||||
features: []
|
||||
})
|
||||
})
|
||||
|
||||
// Mock API fetches
|
||||
;(global.fetch as any)
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => [] }) // watched
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => [] }) // pins
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({}) }) // summary
|
||||
})
|
||||
|
||||
it('renders map container', async () => {
|
||||
render(<WatchedMap />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('map-container')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('displays add watched item button', async () => {
|
||||
render(<WatchedMap />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Watched Map')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Add Watched Item/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('fetches watched data on mount', async () => {
|
||||
render(<WatchedMap />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/watched')
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/pins')
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/watched/summary')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
9
frontend/src/test/setup.ts
Normal file
9
frontend/src/test/setup.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { expect, afterEach } from 'vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
|
||||
// Cleanup after each test
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
167
frontend/src/utils/countries.ts
Normal file
167
frontend/src/utils/countries.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Country code to name and flag emoji mappings
|
||||
* Based on ISO 3166-1 alpha-2 codes
|
||||
*/
|
||||
|
||||
export interface CountryInfo {
|
||||
name: string
|
||||
flag: string
|
||||
}
|
||||
|
||||
// Country code to country name mapping
|
||||
const countryNames: Record<string, string> = {
|
||||
'US': 'United States',
|
||||
'GB': 'United Kingdom',
|
||||
'CA': 'Canada',
|
||||
'AU': 'Australia',
|
||||
'NZ': 'New Zealand',
|
||||
'IE': 'Ireland',
|
||||
'FR': 'France',
|
||||
'DE': 'Germany',
|
||||
'IT': 'Italy',
|
||||
'ES': 'Spain',
|
||||
'PT': 'Portugal',
|
||||
'NL': 'Netherlands',
|
||||
'BE': 'Belgium',
|
||||
'CH': 'Switzerland',
|
||||
'AT': 'Austria',
|
||||
'SE': 'Sweden',
|
||||
'NO': 'Norway',
|
||||
'DK': 'Denmark',
|
||||
'FI': 'Finland',
|
||||
'PL': 'Poland',
|
||||
'CZ': 'Czech Republic',
|
||||
'GR': 'Greece',
|
||||
'RU': 'Russia',
|
||||
'JP': 'Japan',
|
||||
'CN': 'China',
|
||||
'KR': 'South Korea',
|
||||
'IN': 'India',
|
||||
'TH': 'Thailand',
|
||||
'VN': 'Vietnam',
|
||||
'PH': 'Philippines',
|
||||
'ID': 'Indonesia',
|
||||
'MY': 'Malaysia',
|
||||
'SG': 'Singapore',
|
||||
'MX': 'Mexico',
|
||||
'BR': 'Brazil',
|
||||
'AR': 'Argentina',
|
||||
'CL': 'Chile',
|
||||
'CO': 'Colombia',
|
||||
'PE': 'Peru',
|
||||
'ZA': 'South Africa',
|
||||
'EG': 'Egypt',
|
||||
'NG': 'Nigeria',
|
||||
'KE': 'Kenya',
|
||||
'IL': 'Israel',
|
||||
'TR': 'Turkey',
|
||||
'SA': 'Saudi Arabia',
|
||||
'AE': 'United Arab Emirates',
|
||||
'IR': 'Iran',
|
||||
'PK': 'Pakistan',
|
||||
'BD': 'Bangladesh',
|
||||
'LK': 'Sri Lanka',
|
||||
'NP': 'Nepal',
|
||||
'MM': 'Myanmar',
|
||||
'KH': 'Cambodia',
|
||||
'LA': 'Laos',
|
||||
'TW': 'Taiwan',
|
||||
'HK': 'Hong Kong',
|
||||
'MO': 'Macau',
|
||||
}
|
||||
|
||||
// Country code to flag emoji mapping
|
||||
const countryFlags: Record<string, string> = {
|
||||
'US': '🇺🇸',
|
||||
'GB': '🇬🇧',
|
||||
'CA': '🇨🇦',
|
||||
'AU': '🇦🇺',
|
||||
'NZ': '🇳🇿',
|
||||
'IE': '🇮🇪',
|
||||
'FR': '🇫🇷',
|
||||
'DE': '🇩🇪',
|
||||
'IT': '🇮🇹',
|
||||
'ES': '🇪🇸',
|
||||
'PT': '🇵🇹',
|
||||
'NL': '🇳🇱',
|
||||
'BE': '🇧🇪',
|
||||
'CH': '🇨🇭',
|
||||
'AT': '🇦🇹',
|
||||
'SE': '🇸🇪',
|
||||
'NO': '🇳🇴',
|
||||
'DK': '🇩🇰',
|
||||
'FI': '🇫🇮',
|
||||
'PL': '🇵🇱',
|
||||
'CZ': '🇨🇿',
|
||||
'GR': '🇬🇷',
|
||||
'RU': '🇷🇺',
|
||||
'JP': '🇯🇵',
|
||||
'CN': '🇨🇳',
|
||||
'KR': '🇰🇷',
|
||||
'IN': '🇮🇳',
|
||||
'TH': '🇹🇭',
|
||||
'VN': '🇻🇳',
|
||||
'PH': '🇵🇭',
|
||||
'ID': '🇮🇩',
|
||||
'MY': '🇲🇾',
|
||||
'SG': '🇸🇬',
|
||||
'MX': '🇲🇽',
|
||||
'BR': '🇧🇷',
|
||||
'AR': '🇦🇷',
|
||||
'CL': '🇨🇱',
|
||||
'CO': '🇨🇴',
|
||||
'PE': '🇵🇪',
|
||||
'ZA': '🇿🇦',
|
||||
'EG': '🇪🇬',
|
||||
'NG': '🇳🇬',
|
||||
'KE': '🇰🇪',
|
||||
'IL': '🇮🇱',
|
||||
'TR': '🇹🇷',
|
||||
'SA': '🇸🇦',
|
||||
'AE': '🇦🇪',
|
||||
'IR': '🇮🇷',
|
||||
'PK': '🇵🇰',
|
||||
'BD': '🇧🇩',
|
||||
'LK': '🇱🇰',
|
||||
'NP': '🇳🇵',
|
||||
'MM': '🇲🇲',
|
||||
'KH': '🇰🇭',
|
||||
'LA': '🇱🇦',
|
||||
'TW': '🇹🇼',
|
||||
'HK': '🇭🇰',
|
||||
'MO': '🇲🇴',
|
||||
}
|
||||
|
||||
/**
|
||||
* Get country name from country code
|
||||
*/
|
||||
export function getCountryName(countryCode: string): string {
|
||||
return countryNames[countryCode.toUpperCase()] || countryCode.toUpperCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get flag emoji from country code
|
||||
*/
|
||||
export function getCountryFlag(countryCode: string): string {
|
||||
return countryFlags[countryCode.toUpperCase()] || '🏳️'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full country info (name and flag)
|
||||
*/
|
||||
export function getCountryInfo(countryCode: string): CountryInfo {
|
||||
const code = countryCode.toUpperCase()
|
||||
return {
|
||||
name: countryNames[code] || code,
|
||||
flag: countryFlags[code] || '🏳️',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format country display string with flag and name
|
||||
*/
|
||||
export function formatCountryDisplay(countryCode: string): string {
|
||||
const info = getCountryInfo(countryCode)
|
||||
return `${info.flag} ${info.name}`
|
||||
}
|
||||
|
||||
43
frontend/src/utils/tmdb.ts
Normal file
43
frontend/src/utils/tmdb.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* TMDB API client utilities
|
||||
*/
|
||||
|
||||
export interface TMDBResult {
|
||||
id: number
|
||||
title: string
|
||||
year: number | null
|
||||
type: 'movie' | 'tv'
|
||||
overview?: string
|
||||
poster_path?: string
|
||||
}
|
||||
|
||||
export interface TMDBSearchResponse {
|
||||
query: string
|
||||
type: 'movie' | 'tv'
|
||||
results: TMDBResult[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Search TMDB for movies or TV shows
|
||||
*/
|
||||
export async function searchTMDB(
|
||||
query: string,
|
||||
type: 'movie' | 'tv' = 'movie'
|
||||
): Promise<TMDBResult[]> {
|
||||
if (!query.trim()) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/tmdb/search?query=${encodeURIComponent(query)}&type=${type}`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`TMDB search failed: ${response.statusText}`)
|
||||
}
|
||||
const data: TMDBSearchResponse = await response.json()
|
||||
return data.results || []
|
||||
} catch (error) {
|
||||
console.error('TMDB search error:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,4 +25,9 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.ts',
|
||||
},
|
||||
})
|
||||
|
||||
43
nix/test-vm-simple.nix
Normal file
43
nix/test-vm-simple.nix
Normal file
@@ -0,0 +1,43 @@
|
||||
# Simplified NixOS VM configuration for testing Movie Map
|
||||
# This focuses on PostgreSQL; *arr services should be configured separately
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
{
|
||||
networking = {
|
||||
hostName = "moviemap-test-vm";
|
||||
firewall = {
|
||||
enable = true;
|
||||
allowedTCPPorts = [
|
||||
8080 # Movie Map backend
|
||||
5432 # PostgreSQL
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
# PostgreSQL configuration
|
||||
services.postgresql = {
|
||||
enable = true;
|
||||
ensureDatabases = [ "moviemap_test" ];
|
||||
ensureUsers = [
|
||||
{
|
||||
name = "moviemap";
|
||||
ensureDBOwnership = true;
|
||||
}
|
||||
];
|
||||
authentication = ''
|
||||
local all all trust
|
||||
host all all 0.0.0.0/0 trust
|
||||
'';
|
||||
settings = {
|
||||
listen_addresses = "'*'";
|
||||
};
|
||||
};
|
||||
|
||||
# System packages
|
||||
environment.systemPackages = with pkgs; [
|
||||
curl
|
||||
jq
|
||||
postgresql
|
||||
];
|
||||
}
|
||||
|
||||
102
nix/test-vm.nix
Normal file
102
nix/test-vm.nix
Normal file
@@ -0,0 +1,102 @@
|
||||
# NixOS VM configuration for testing Movie Map
|
||||
# This VM includes PostgreSQL, Radarr, Sonarr, and Lidarr with test data
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
{
|
||||
# Enable QEMU guest agent for better VM management
|
||||
services.qemuGuest.enable = true;
|
||||
|
||||
# Networking - allow external access
|
||||
networking = {
|
||||
hostName = "moviemap-test-vm";
|
||||
firewall = {
|
||||
enable = true;
|
||||
allowedTCPPorts = [
|
||||
8080 # Movie Map backend
|
||||
5432 # PostgreSQL
|
||||
7878 # Radarr
|
||||
8989 # Sonarr
|
||||
8686 # Lidarr
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
# PostgreSQL configuration
|
||||
services.postgresql = {
|
||||
enable = true;
|
||||
ensureDatabases = [ "moviemap_test" ];
|
||||
ensureUsers = [
|
||||
{
|
||||
name = "moviemap";
|
||||
ensureDBOwnership = true;
|
||||
}
|
||||
];
|
||||
authentication = ''
|
||||
local all all trust
|
||||
host all all 0.0.0.0/0 trust
|
||||
'';
|
||||
settings = {
|
||||
listen_addresses = "'*'";
|
||||
};
|
||||
};
|
||||
|
||||
# Radarr configuration
|
||||
services.radarr = {
|
||||
enable = true;
|
||||
openFirewall = true;
|
||||
user = "radarr";
|
||||
group = "radarr";
|
||||
};
|
||||
|
||||
# Sonarr configuration
|
||||
services.sonarr = {
|
||||
enable = true;
|
||||
openFirewall = true;
|
||||
user = "sonarr";
|
||||
group = "sonarr";
|
||||
};
|
||||
|
||||
# Lidarr configuration
|
||||
services.lidarr = {
|
||||
enable = true;
|
||||
openFirewall = true;
|
||||
user = "lidarr";
|
||||
group = "lidarr";
|
||||
};
|
||||
|
||||
# Create test API keys for *arr services
|
||||
# These will be set via environment variables in the CI/CD
|
||||
# For now, we'll create a script that generates them
|
||||
systemd.services.setup-arr-services = {
|
||||
description = "Setup *arr services with test API keys";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "radarr.service" "sonarr.service" "lidarr.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
script = ''
|
||||
# Wait for services to be ready
|
||||
sleep 10
|
||||
|
||||
# Note: In a real setup, you would configure API keys via the *arr APIs
|
||||
# For testing, we'll use environment variables set by CI/CD
|
||||
echo "Test VM setup complete"
|
||||
'';
|
||||
};
|
||||
|
||||
# Environment variables for test configuration
|
||||
environment.variables = {
|
||||
TEST_RADARR_URL = "http://localhost:7878";
|
||||
TEST_SONARR_URL = "http://localhost:8989";
|
||||
TEST_LIDARR_URL = "http://localhost:8686";
|
||||
};
|
||||
|
||||
# System packages
|
||||
environment.systemPackages = with pkgs; [
|
||||
curl
|
||||
jq
|
||||
postgresql
|
||||
];
|
||||
}
|
||||
|
||||
60
scripts/setup-test-vm.sh
Executable file
60
scripts/setup-test-vm.sh
Executable file
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
# Script to set up a test VM with *arr services
|
||||
# This script can be run in CI/CD or manually to configure test services
|
||||
|
||||
set -e
|
||||
|
||||
echo "Setting up test VM for Movie Map..."
|
||||
|
||||
# Configuration
|
||||
RADARR_URL="${RADARR_URL:-http://localhost:7878}"
|
||||
SONARR_URL="${SONARR_URL:-http://localhost:8989}"
|
||||
LIDARR_URL="${LIDARR_URL:-http://localhost:8686}"
|
||||
|
||||
# Test API keys (should be set via environment variables in CI/CD)
|
||||
RADARR_API_KEY="${RADARR_API_KEY:-test-radarr-key}"
|
||||
SONARR_API_KEY="${SONARR_API_KEY:-test-sonarr-key}"
|
||||
LIDARR_API_KEY="${LIDARR_API_KEY:-test-lidarr-key}"
|
||||
|
||||
echo "Waiting for services to be ready..."
|
||||
sleep 10
|
||||
|
||||
# Function to wait for service to be ready
|
||||
wait_for_service() {
|
||||
local url=$1
|
||||
local name=$2
|
||||
local max_attempts=30
|
||||
local attempt=0
|
||||
|
||||
echo "Waiting for $name to be ready at $url..."
|
||||
while [ $attempt -lt $max_attempts ]; do
|
||||
if curl -s -f "$url/api/v3/system/status" > /dev/null 2>&1 || \
|
||||
curl -s -f "$url/api/v3/system/status" > /dev/null 2>&1 || \
|
||||
curl -s -f "$url/api/v1/system/status" > /dev/null 2>&1; then
|
||||
echo "$name is ready!"
|
||||
return 0
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "Warning: $name did not become ready in time"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Wait for services (if they're running)
|
||||
wait_for_service "$RADARR_URL" "Radarr" || true
|
||||
wait_for_service "$SONARR_URL" "Sonarr" || true
|
||||
wait_for_service "$LIDARR_URL" "Lidarr" || true
|
||||
|
||||
echo "Test VM setup complete!"
|
||||
echo "RADARR_URL=$RADARR_URL"
|
||||
echo "SONARR_URL=$SONARR_URL"
|
||||
echo "LIDARR_URL=$LIDARR_URL"
|
||||
echo ""
|
||||
echo "Note: In a real CI/CD environment, you would:"
|
||||
echo "1. Start *arr services (via Docker, NixOS, or other means)"
|
||||
echo "2. Configure API keys via their web interfaces or APIs"
|
||||
echo "3. Add test data (10 movies, 10 shows, 10 artists)"
|
||||
echo "4. Set environment variables for the test suite"
|
||||
|
||||
Reference in New Issue
Block a user