Add new API endpoints for media retrieval by country and enhance configuration
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:
Danilo Reyes
2025-12-28 22:35:06 -06:00
parent 4caba81599
commit 2b1a92fb49
32 changed files with 2733 additions and 76 deletions

97
.gitea/workflows/test.yml Normal file
View 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

View File

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

View 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
View 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)}")

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
"""Test suite for Movie Map backend"""

200
backend/tests/conftest.py Normal file
View 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
View 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"] == []

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

View File

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

View File

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

View 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;
}

View 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>
)
}

View File

@@ -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],
}
return centers[countryCode] || null
const handleCountryClick = (countryCode: string) => {
setSelectedCountry(countryCode)
}
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>
)
}

View 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;
}

View 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>
)
}

View File

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

View 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;
}

View 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>
)
}

View File

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

View 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')
)
})
})
})

View 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')
})
})
})

View 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()
})

View 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}`
}

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

View File

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