commit 6f551ba4391c0a17f0c3691bc0d6b712de8b95be Author: snoopy Date: Tue May 19 11:40:44 2026 +0000 Initial release — MediaTracker v1.0 Dockerized media library scanner for NAS drives. Identifies movies and series from filenames, fetches posters via the public IMDB suggestion API (no key required), and exposes a dark-themed web UI with multilingual search support. Co-Authored-By: Claude Sonnet 4.6 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0accfe6 --- /dev/null +++ b/.env.example @@ -0,0 +1,30 @@ +# ── Base de données MariaDB ───────────────────────────────────────────────── +DB_ROOT_PASSWORD=changeme_root +DB_NAME=mediatracker +DB_USER=mediatracker +DB_PASSWORD=changeme_db + +# ── Application ───────────────────────────────────────────────────────────── +# Port exposé sur l'hôte +APP_PORT=8080 + +# Intervalle du scan incrémental automatique (en heures) +SCAN_INTERVAL_HOURS=6 + +# ── Sources NAS ────────────────────────────────────────────────────────────── +# Format : NAS_X=Nom Affiché|/chemin/hôte/vers/montage +# Le chemin doit exister sur l'hôte avant de lancer generate-compose.py. +# Vous pouvez déclarer autant de NAS que nécessaire (NAS_1, NAS_2, NAS_3…). +NAS_1=NAS Principal|/mnt/nas1 +# NAS_2=NAS Films 4K|/mnt/nas2 +# NAS_3=NAS Séries|/mnt/nas3 + +# ── Extensions vidéo reconnues ─────────────────────────────────────────────── +VIDEO_EXTENSIONS=.mkv,.mp4,.avi,.m4v,.mov,.wmv,.ts,.m2ts,.iso + +# ── Fuseau horaire ─────────────────────────────────────────────────────────── +TZ=Europe/Paris + +# ── Métadonnées & jaquettes ────────────────────────────────────────────────── +# Les jaquettes et identifiants IMDB sont récupérés automatiquement via +# l'API publique de suggestion IMDB (aucune clé API requise). diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a984905 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Environment — never commit secrets +.env + +# Auto-generated by generate-compose.py — contains host-specific paths +docker-compose.override.yml + +# Python +__pycache__/ +**/__pycache__/ +*.py[cod] +*.pyo +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +*.egg-info/ +dist/ +build/ +.venv/ +venv/ + +# Logs +*.log +logs/ + +# macOS +.DS_Store + +# Editor +.vscode/ +.idea/ +*.swp +*.swo diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..01fd3f4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MediaTracker contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8994262 --- /dev/null +++ b/README.md @@ -0,0 +1,225 @@ +# MediaTracker + +Application Dockerisée de recensement de médiathèque. Scanne vos partages NAS en lecture seule, identifie automatiquement films et séries à partir des noms de fichiers, récupère les jaquettes via l'API publique IMDB, et expose une interface web pour rechercher votre catalogue — y compris en plusieurs langues. + +--- + +## Fonctionnalités + +- **Scan automatique** : scan complet au premier démarrage, puis scan incrémental périodique (configurable) +- **Détection films/séries** : parse-torrent-name reconnaît la grande majorité des conventions de nommage torrent +- **Jaquettes automatiques** : récupérées via l'API de suggestion IMDB publique (sans clé API) +- **Recherche multilingue** : cherchez en français ou en anglais — les titres sont croisés avec IMDB en temps réel +- **Multi-NAS** : autant de sources que nécessaire, montées en lecture seule +- **Affiches locales** : si un `poster.jpg` / `folder.jpg` existe à côté du fichier, il est utilisé en priorité +- **Interface web** : thème sombre, cartes avec jaquette, modal de détail, panneau de scan avec historique + +--- + +## Stack technique + +| Composant | Technologie | +|------------|-------------------------------------| +| Backend | Python 3.12 · FastAPI · SQLAlchemy | +| Base de données | MariaDB 11 | +| Planificateur | APScheduler | +| Parser | parse-torrent-name (PTN) | +| Métadonnées | IMDB suggestion API (public) | +| Frontend | HTML/CSS/JS vanilla · Nginx | +| Conteneurs | Docker Compose v2 | + +--- + +## Prérequis + +- **Docker** ≥ 24 et **Docker Compose** ≥ v2 +- **Python 3.x** sur l'hôte (pour `generate-compose.py`, stdlib uniquement) +- Vos partages NAS montés sur le système hôte (voir ci-dessous) + +--- + +## Monter un partage NAS (SMB/CIFS) + +Si votre NAS est accessible en SMB, installez `cifs-utils` puis montez le partage : + +```bash +sudo apt install cifs-utils # Debian/Ubuntu +sudo mkdir -p /mnt/nas1 + +# Montage manuel (test) +sudo mount -t cifs //192.168.1.100/Videotheque /mnt/nas1 \ + -o username=VOTRE_USER,password=VOTRE_PASS,uid=$(id -u),gid=$(id -g),iocharset=utf8 + +# Montage permanent via /etc/fstab +//192.168.1.100/Videotheque /mnt/nas1 cifs credentials=/etc/nas-creds,uid=1000,gid=1000,iocharset=utf8,_netdev 0 0 +``` + +`/etc/nas-creds` (permissions `600`) : +``` +username=VOTRE_USER +password=VOTRE_PASS +``` + +> MediaTracker ne monte rien lui-même et n'écrit jamais sur le NAS. + +--- + +## Installation + +### 1. Cloner le dépôt + +```bash +git clone https://github.com/votre-repo/mediatracker.git +cd mediatracker +``` + +### 2. Configurer l'environnement + +```bash +cp .env.example .env +``` + +Éditez `.env` et adaptez a minima : + +```env +DB_ROOT_PASSWORD=un_mot_de_passe_fort +DB_PASSWORD=un_autre_mot_de_passe + +NAS_1=NAS Principal|/mnt/nas1 +# NAS_2=Séries|/mnt/nas2 +``` + +### 3. Générer le fichier Docker Compose override + +```bash +python3 generate-compose.py +``` + +Ce script lit `.env`, vérifie que les chemins existent, et génère `docker-compose.override.yml` avec les volumes en lecture seule. Relancez-le à chaque modification des entrées `NAS_*`. + +### 4. Lancer l'application + +```bash +docker compose -f docker-compose.yml -f docker-compose.override.yml up -d --build +``` + +L'interface est disponible sur **http://localhost:8080** (ou le port défini par `APP_PORT`). + +Au premier démarrage, un scan complet est lancé automatiquement. + +--- + +## Ajouter ou modifier un NAS + +1. Éditez `.env` (ajoutez ou modifiez une entrée `NAS_X`) +2. Régénérez l'override : `python3 generate-compose.py` +3. Redémarrez le backend : + +```bash +docker compose -f docker-compose.yml -f docker-compose.override.yml up -d backend +``` + +--- + +## Conventions de nommage supportées + +MediaTracker utilise [parse-torrent-name](https://github.com/platelminto/parse-torrent-name) pour analyser les noms de fichiers. + +### Films + +``` +The.Dark.Knight.2008.1080p.BluRay.x264.mkv +Inception (2010) 4K HDR.mkv +Parasite.2019.FRENCH.1080p.WEB-DL.mp4 +Dune Part Two (2024).mkv +``` + +### Séries + +``` +Breaking.Bad.S01E01.1080p.mkv +Game.of.Thrones.S08E06.The.Iron.Throne.720p.mkv +/Stranger Things/Season 2/Stranger.Things.S02E04.mkv +/The.Wire/Saison 3/S03E01.mkv +``` + +Les dossiers nommés `Season X`, `Saison X` ou `SXX` sont automatiquement détectés comme indicateurs de saison. + +--- + +## Recherche multilingue + +La recherche fonctionne en français comme en anglais sans configuration supplémentaire. + +**Mécanisme :** lors d'une recherche, la requête est envoyée à l'API de suggestion IMDB (publique, multilingue). Les identifiants IMDB retournés sont croisés avec la colonne `imdb_id` de la base locale. Ainsi, chercher « chevalier noir » retrouve un film indexé sous son titre anglais « The Dark Knight » si son `imdb_id` est présent en base. + +Les `imdb_id` sont peuplés automatiquement lors du scan (phase de récupération des jaquettes). + +--- + +## API Reference + +| Méthode | Endpoint | Description | +|---------|--------------------------------------|------------------------------------| +| GET | `/api/search?q=…&type=all\|movie\|series` | Recherche (titre + IMDB cross-ref) | +| POST | `/api/scan/full` | Déclenche un scan complet | +| POST | `/api/scan/incremental` | Déclenche un scan incrémental | +| GET | `/api/scan/status/{id}` | Statut d'un scan | +| GET | `/api/scan/history` | 20 derniers scans | +| GET | `/api/stats` | Compteurs globaux | +| GET | `/api/nas` | Liste des NAS et leur statut | +| GET | `/api/health` | Healthcheck | +| GET | `/api/local-poster?path=…` | Sert une jaquette locale depuis le NAS | + +--- + +## Troubleshooting + +### NAS inaccessible au démarrage + +Le backend démarre même si un NAS est inaccessible. Vérifiez les logs : + +```bash +docker compose logs backend | grep -i nas +``` + +Assurez-vous que le point de montage hôte existe **avant** de lancer `generate-compose.py`. + +### Fichiers non reconnus + +Les fichiers que PTN ne parvient pas à analyser sont enregistrés dans la table `unmatched_files`. Consultez-les directement en base : + +```sql +SELECT file_path, reason FROM unmatched_files ORDER BY scanned_at DESC LIMIT 50; +``` + +Ou via les stats de l'interface (compteur « non reconnus »). + +### Jaquettes manquantes après le scan + +Les jaquettes sont récupérées en parallèle après le scan de fichiers (10 workers). Si le réseau est lent ou IMDB indisponible, relancez un scan incrémental : + +```bash +curl -X POST http://localhost:8080/api/scan/incremental +``` + +### Réinitialiser complètement la base de données + +```bash +docker compose down -v +docker compose -f docker-compose.yml -f docker-compose.override.yml up -d --build +``` + +> Toutes les données sont supprimées. Un scan complet repart automatiquement. + +### Voir les logs en temps réel + +```bash +docker compose logs -f backend +``` + +--- + +## Licence + +MIT — voir [LICENSE](LICENSE). diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..c29b34f --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + default-libmysqlclient-dev \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN mkdir -p /nas + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "info"] diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..ff28f8c --- /dev/null +++ b/backend/config.py @@ -0,0 +1,71 @@ +import os +import logging +from dataclasses import dataclass, field + +logger = logging.getLogger(__name__) + + +@dataclass +class NasConfig: + index: int + name: str + container_path: str + + +@dataclass +class Settings: + db_host: str + db_port: int + db_name: str + db_user: str + db_password: str + scan_interval_hours: int + video_extensions: frozenset[str] + nas_list: list[NasConfig] + + @property + def db_url(self) -> str: + return ( + f"mysql://{self.db_user}:{self.db_password}" + f"@{self.db_host}:{self.db_port}/{self.db_name}" + "?charset=utf8mb4" + ) + + +def _discover_nas() -> list[NasConfig]: + """Detect mounted NAS volumes inside the container (/nas/nas1, /nas/nas2, …).""" + nas_base = "/nas" + entries: list[NasConfig] = [] + idx = 1 + while True: + container_path = f"{nas_base}/nas{idx}" + if not os.path.isdir(container_path): + break + # Attempt to read the name from env (set by generate-compose.py indirectly via NAS_X) + env_key = f"NAS_{idx}" + raw = os.getenv(env_key, "") + if raw and "|" in raw: + name = raw.split("|", 1)[0].strip() + else: + name = f"NAS {idx}" + entries.append(NasConfig(index=idx, name=name, container_path=container_path)) + idx += 1 + if not entries: + logger.warning("No NAS volumes found at %s/nasX — scanner will have nothing to scan", nas_base) + return entries + + +def load_settings() -> Settings: + raw_ext = os.getenv("VIDEO_EXTENSIONS", ".mkv,.mp4,.avi,.m4v,.mov,.wmv,.ts,.m2ts,.iso") + extensions = frozenset(e.strip().lower() for e in raw_ext.split(",") if e.strip()) + + return Settings( + db_host=os.getenv("DB_HOST", "db"), + db_port=int(os.getenv("DB_PORT", "3306")), + db_name=os.getenv("DB_NAME", "mediatracker"), + db_user=os.getenv("DB_USER", "mediatracker"), + db_password=os.getenv("DB_PASSWORD", "changeme_db"), + scan_interval_hours=int(os.getenv("SCAN_INTERVAL_HOURS", "6")), + video_extensions=extensions, + nas_list=_discover_nas(), + ) diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..ff4c7de --- /dev/null +++ b/backend/database.py @@ -0,0 +1,194 @@ +import logging +from datetime import datetime +from typing import Optional + +from sqlalchemy import ( + create_engine, + text, + Column, Integer, SmallInteger, BigInteger, String, Text, + DateTime, Enum, ForeignKey, UniqueConstraint, Index, and_, +) +from sqlalchemy.orm import ( + DeclarativeBase, + relationship, + Session, + sessionmaker, +) + + +logger = logging.getLogger(__name__) + + +class Base(DeclarativeBase): + pass + + +# ───────────────────────────────────────── +# ORM models +# ───────────────────────────────────────── + +class NasSource(Base): + __tablename__ = "nas_sources" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(255), nullable=False) + path = Column(String(1024), nullable=False) + last_scan = Column(DateTime, nullable=True) + + movies = relationship("Movie", back_populates="nas", cascade="all, delete-orphan") + seasons = relationship("Season", back_populates="nas", cascade="all, delete-orphan") + unmatched = relationship("UnmatchedFile", back_populates="nas", cascade="all, delete-orphan") + + +class Movie(Base): + __tablename__ = "movies" + __table_args__ = ( + UniqueConstraint("file_path", name="uq_movie_path"), + Index("idx_movie_title", "title"), + ) + + id = Column(Integer, primary_key=True, autoincrement=True) + title = Column(String(512), nullable=False) + year = Column(SmallInteger, nullable=True) + quality = Column(String(64), nullable=True) + codec = Column(String(64), nullable=True) + file_path = Column(String(2048), nullable=False) + file_size = Column(BigInteger, nullable=True) + poster_url = Column(String(512), nullable=True) + imdb_id = Column(String(20), nullable=True) + nas_id = Column(Integer, ForeignKey("nas_sources.id", ondelete="CASCADE"), nullable=False) + added_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + nas = relationship("NasSource", back_populates="movies") + + +class Series(Base): + __tablename__ = "series" + __table_args__ = ( + UniqueConstraint("title_normalized", name="uq_series_normalized"), + Index("idx_series_title", "title"), + ) + + id = Column(Integer, primary_key=True, autoincrement=True) + title = Column(String(512), nullable=False) + title_normalized = Column(String(512), nullable=False) + year = Column(SmallInteger, nullable=True) + poster_url = Column(String(512), nullable=True) + imdb_id = Column(String(20), nullable=True) + added_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + seasons = relationship("Season", back_populates="series", cascade="all, delete-orphan") + + +class Season(Base): + __tablename__ = "seasons" + __table_args__ = ( + UniqueConstraint("series_id", "season_number", "nas_id", name="uq_season"), + ) + + id = Column(Integer, primary_key=True, autoincrement=True) + series_id = Column(Integer, ForeignKey("series.id", ondelete="CASCADE"), nullable=False) + season_number = Column(SmallInteger, nullable=False) + nas_id = Column(Integer, ForeignKey("nas_sources.id", ondelete="CASCADE"), nullable=False) + + series = relationship("Series", back_populates="seasons") + nas = relationship("NasSource", back_populates="seasons") + episodes = relationship("Episode", back_populates="season", cascade="all, delete-orphan") + + +class Episode(Base): + __tablename__ = "episodes" + __table_args__ = ( + UniqueConstraint("file_path", name="uq_episode_path"), + ) + + id = Column(Integer, primary_key=True, autoincrement=True) + season_id = Column(Integer, ForeignKey("seasons.id", ondelete="CASCADE"), nullable=False) + episode_number = Column(SmallInteger, nullable=False) + title = Column(String(512), nullable=True) + quality = Column(String(64), nullable=True) + codec = Column(String(64), nullable=True) + file_path = Column(String(2048), nullable=False) + file_size = Column(BigInteger, nullable=True) + added_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + season = relationship("Season", back_populates="episodes") + + +class UnmatchedFile(Base): + __tablename__ = "unmatched_files" + + id = Column(Integer, primary_key=True, autoincrement=True) + file_path = Column(String(2048), nullable=False) + nas_id = Column(Integer, ForeignKey("nas_sources.id", ondelete="CASCADE"), nullable=False) + reason = Column(String(512), nullable=False) + scanned_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + nas = relationship("NasSource", back_populates="unmatched") + + +class ScanHistory(Base): + __tablename__ = "scan_history" + + id = Column(Integer, primary_key=True, autoincrement=True) + scan_type = Column(Enum("full", "incremental"), nullable=False) + status = Column(Enum("running", "completed", "failed"), nullable=False, default="running") + started_at = Column(DateTime, default=datetime.utcnow, nullable=False) + finished_at = Column(DateTime, nullable=True) + files_found = Column(Integer, nullable=False, default=0) + files_added = Column(Integer, nullable=False, default=0) + files_removed = Column(Integer, nullable=False, default=0) + files_unmatched = Column(Integer, nullable=False, default=0) + error_message = Column(Text, nullable=True) + + +class AltTitle(Base): + __tablename__ = "alt_titles" + __table_args__ = ( + UniqueConstraint("media_type", "media_id", "lang", "title", name="uq_alt_title"), + Index("idx_alt_title_search", "title"), + ) + + id = Column(Integer, primary_key=True, autoincrement=True) + media_type = Column(Enum("movie", "series"), nullable=False) + media_id = Column(Integer, nullable=False) + title = Column(String(512), nullable=False) + lang = Column(String(10), nullable=False) + + +# ───────────────────────────────────────── +# Engine / session factory +# ───────────────────────────────────────── + +_engine = None +_SessionLocal = None + + +def init_db(db_url: str) -> None: + global _engine, _SessionLocal + _engine = create_engine( + db_url, + pool_pre_ping=True, + pool_recycle=3600, + connect_args={"connect_timeout": 10}, + ) + _SessionLocal = sessionmaker(bind=_engine, autocommit=False, autoflush=False) + logger.info("Database engine initialized") + + +def get_session() -> Session: + if _SessionLocal is None: + raise RuntimeError("Database not initialized — call init_db() first") + return _SessionLocal() + + +def check_connection() -> bool: + try: + with _engine.connect() as conn: + conn.execute(text("SELECT 1")) + return True + except Exception as exc: + logger.error("Database connection check failed: %s", exc) + return False diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..d8c6624 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,125 @@ +""" +main.py — FastAPI application entry point. +""" + +import logging +import mimetypes +import time +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI, HTTPException, Query +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse + +from config import load_settings +from database import check_connection, init_db, get_session +from routers import scan, search, stats +from scheduler import start_scheduler, stop_scheduler + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%Y-%m-%dT%H:%M:%S", +) +logger = logging.getLogger(__name__) + +_MAX_DB_RETRIES = 15 +_DB_RETRY_DELAY = 4 + + +def _migrate_db() -> None: + """Add columns introduced after initial schema creation.""" + session = get_session() + migrations = [ + "ALTER TABLE movies ADD COLUMN poster_url VARCHAR(512) NULL", + "ALTER TABLE series ADD COLUMN poster_url VARCHAR(512) NULL", + "ALTER TABLE movies ADD COLUMN imdb_id VARCHAR(20) NULL", + "ALTER TABLE series ADD COLUMN imdb_id VARCHAR(20) NULL", + """CREATE TABLE IF NOT EXISTS alt_titles ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + media_type ENUM('movie','series') NOT NULL, + media_id INT UNSIGNED NOT NULL, + title VARCHAR(512) NOT NULL, + lang VARCHAR(10) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_alt_title (media_type, media_id, lang, title(255)), + INDEX idx_alt_title_search (title(255)) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""", + ] + try: + for sql in migrations: + try: + session.execute(__import__("sqlalchemy").text(sql)) + session.commit() + except Exception: + session.rollback() + finally: + session.close() + + +def _wait_for_db() -> None: + for attempt in range(1, _MAX_DB_RETRIES + 1): + if check_connection(): + logger.info("Database connection established") + return + logger.warning("Database not ready (attempt %d/%d) — retrying in %ds", + attempt, _MAX_DB_RETRIES, _DB_RETRY_DELAY) + time.sleep(_DB_RETRY_DELAY) + raise RuntimeError("Could not connect to the database after multiple attempts") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + settings = load_settings() + app.state.settings = settings + + logger.info("Initializing database connection to %s:%s/%s", + settings.db_host, settings.db_port, settings.db_name) + init_db(settings.db_url) + _wait_for_db() + _migrate_db() + + logger.info("Poster source: IMDB public suggestion API (no key required)") + start_scheduler(settings) + logger.info("MediaTracker backend ready") + + yield + + stop_scheduler() + logger.info("MediaTracker backend shutdown") + + +app = FastAPI(title="MediaTracker API", version="1.0.0", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(search.router) +app.include_router(scan.router) +app.include_router(stats.router) + + +@app.get("/api/health") +def health(): + return {"status": "ok"} + + +@app.get("/api/local-poster") +def local_poster(path: str = Query(...)): + """ + Serve a local poster image file from the NAS. + Path must be under /nas/ (read-only mount — no traversal possible outside). + """ + resolved = Path(path).resolve() + nas_root = Path("/nas").resolve() + if not str(resolved).startswith(str(nas_root)): + raise HTTPException(status_code=403, detail="Access denied") + if not resolved.is_file(): + raise HTTPException(status_code=404, detail="Poster file not found") + mime, _ = mimetypes.guess_type(str(resolved)) + return FileResponse(str(resolved), media_type=mime or "image/jpeg") diff --git a/backend/metadata.py b/backend/metadata.py new file mode 100644 index 0000000..a9c1090 --- /dev/null +++ b/backend/metadata.py @@ -0,0 +1,178 @@ +""" +metadata.py — Poster + metadata fetching. +- Poster: IMDB public suggestion API (no key required) +- Alt titles (FR/EN): Wikidata SPARQL (free, no registration) +- Local poster detection (poster.jpg / folder.jpg alongside video) +""" + +import json +import logging +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + +_REQUEST_TIMEOUT = 4 +_WIKIDATA_TIMEOUT = 8 +_LOCAL_POSTER_NAMES = ["poster.jpg", "poster.png", "folder.jpg", "folder.png", + "cover.jpg", "cover.png", "movie.jpg"] +_ALT_TITLE_LANGS = ("fr", "en", "de", "es") + + +@dataclass +class MediaMetadata: + poster_url: Optional[str] = None + imdb_id: Optional[str] = None + + +# ─── IMDB suggestion API ───────────────────────────────────────────────────── + +def _imdb_suggest(query: str) -> list[dict]: + query_clean = query.lower().strip() + if not query_clean: + return [] + first_char = query_clean[0] if query_clean[0].isalpha() else "a" + encoded = urllib.parse.quote(query_clean) + url = f"https://sg.media-imdb.com/suggests/{first_char}/{encoded}.json" + try: + req = urllib.request.Request(url, headers={ + "User-Agent": "Mozilla/5.0", + "Accept": "application/json, text/javascript", + }) + with urllib.request.urlopen(req, timeout=_REQUEST_TIMEOUT) as resp: + raw = resp.read().decode("utf-8") + start = raw.index('(') + end = raw.rindex(')') + data = json.loads(raw[start + 1:end]) + return data.get("d", []) + except (ValueError, KeyError): + pass + except urllib.error.HTTPError as exc: + if exc.code != 404: + logger.debug("IMDB suggest HTTP %d for %r", exc.code, query) + except Exception as exc: + logger.debug("IMDB suggest error for %r: %s", query, exc) + return [] + + +def _extract_poster(item: dict) -> Optional[str]: + img = item.get("i") + if isinstance(img, list) and img: + return str(img[0]) + if isinstance(img, dict): + return img.get("imageUrl") + return None + + +def _best_match(items: list[dict], title: str, year: Optional[int], qid_filter: str) -> Optional[dict]: + """Return the best matching IMDB item dict, or None.""" + title_lower = title.lower() + candidates = [it for it in items if it.get("qid") == qid_filter] + if not candidates: + return None + if year: + for it in candidates: + if it.get("l", "").lower() == title_lower and it.get("y") == year: + return it + for it in candidates: + if it.get("l", "").lower() == title_lower: + return it + # First candidate that has a poster + for it in candidates: + if _extract_poster(it): + return it + return candidates[0] if candidates else None + + +def _get_metadata(title: str, year: Optional[int], qid_filter: str) -> MediaMetadata: + items = _imdb_suggest(title) + item = _best_match(items, title, year, qid_filter) + if not item: + return MediaMetadata() + return MediaMetadata( + poster_url=_extract_poster(item), + imdb_id=item.get("id"), + ) + + +def get_movie_metadata(title: str, year: Optional[int]) -> MediaMetadata: + return _get_metadata(title, year, "movie") + + +def get_series_metadata(title: str, year: Optional[int]) -> MediaMetadata: + return _get_metadata(title, year, "tvSeries") + + +# ─── Wikidata alt titles ───────────────────────────────────────────────────── + +def get_alt_titles_batch(imdb_ids: list[str], retries: int = 3) -> dict[str, list[tuple[str, str]]]: + """ + Fetch FR/EN/DE/ES titles from Wikidata for a batch of IMDB IDs. + Returns {imdb_id: [(lang, title), ...]} + Retries up to `retries` times on 429/503 with exponential backoff. + """ + import time as _time + + if not imdb_ids: + return {} + + values = " ".join(f'"{iid}"' for iid in imdb_ids) + langs = " ".join(f'"{l}"' for l in _ALT_TITLE_LANGS) + query = f""" +SELECT ?imdbId ?label WHERE {{ + VALUES ?imdbId {{ {values} }} + ?item wdt:P345 ?imdbId . + ?item rdfs:label ?label . + FILTER(LANG(?label) IN ({langs})) +}} +""" + url = "https://query.wikidata.org/sparql?" + urllib.parse.urlencode({ + "query": query, + "format": "json", + }) + + for attempt in range(retries): + try: + req = urllib.request.Request(url, headers={ + "User-Agent": "MediaTracker/1.0", + "Accept": "application/sparql-results+json", + }) + with urllib.request.urlopen(req, timeout=_WIKIDATA_TIMEOUT) as resp: + data = json.loads(resp.read().decode("utf-8")) + + result: dict[str, list[tuple[str, str]]] = {} + for row in data.get("results", {}).get("bindings", []): + iid = row["imdbId"]["value"] + label = row["label"]["value"] + lang = row["label"].get("xml:lang", "") + result.setdefault(iid, []).append((lang, label)) + return result + + except urllib.error.HTTPError as exc: + if exc.code in (429, 503) and attempt < retries - 1: + wait = 5 * (2 ** attempt) + logger.info("Wikidata rate-limited — retrying in %ds (attempt %d/%d)", wait, attempt + 1, retries) + _time.sleep(wait) + else: + logger.warning("Wikidata batch failed (%d ids): HTTP %d", len(imdb_ids), exc.code) + return {} + except Exception as exc: + logger.warning("Wikidata batch failed (%d ids): %s", len(imdb_ids), exc) + return {} + + return {} + + +# ─── Local poster detection ─────────────────────────────────────────────────── + +def find_local_poster(video_path: Path) -> Optional[Path]: + directory = video_path.parent + for name in _LOCAL_POSTER_NAMES: + candidate = directory / name + if candidate.is_file(): + return candidate + return None diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..d264d6e --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,7 @@ +fastapi==0.115.5 +uvicorn[standard]==0.32.1 +sqlalchemy==2.0.36 +mysqlclient==2.2.4 +parse-torrent-name==1.1.0 +apscheduler==3.10.4 +python-dotenv==1.0.1 diff --git a/backend/routers/__init__.py b/backend/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/routers/scan.py b/backend/routers/scan.py new file mode 100644 index 0000000..03ae6b0 --- /dev/null +++ b/backend/routers/scan.py @@ -0,0 +1,98 @@ +from fastapi import APIRouter, HTTPException, Request + +from database import ScanHistory, get_session +from scheduler import ( + get_active_scan_id, + is_scan_running, + trigger_full_scan, + trigger_incremental_scan, +) + +router = APIRouter(prefix="/api/scan", tags=["scan"]) + + +@router.post("/full") +def start_full_scan(request: Request): + settings = request.app.state.settings + if is_scan_running(): + raise HTTPException(status_code=409, detail="A scan is already running") + scan_id = trigger_full_scan(settings) + if scan_id is None: + raise HTTPException(status_code=409, detail="A scan is already running") + return {"scan_id": scan_id, "type": "full", "status": "running"} + + +@router.post("/incremental") +def start_incremental_scan(request: Request): + settings = request.app.state.settings + if is_scan_running(): + raise HTTPException(status_code=409, detail="A scan is already running") + scan_id = trigger_incremental_scan(settings) + if scan_id is None: + raise HTTPException(status_code=409, detail="A scan is already running") + return {"scan_id": scan_id, "type": "incremental", "status": "running"} + + +@router.get("/status/{scan_id}") +def get_scan_status(scan_id: int): + session = get_session() + try: + record = session.query(ScanHistory).get(scan_id) + if record is None: + raise HTTPException(status_code=404, detail="Scan not found") + return { + "scan_id": record.id, + "type": record.scan_type, + "status": record.status, + "started_at": record.started_at.isoformat() if record.started_at else None, + "finished_at": record.finished_at.isoformat() if record.finished_at else None, + "files_found": record.files_found, + "files_added": record.files_added, + "files_removed": record.files_removed, + "files_unmatched": record.files_unmatched, + "error_message": record.error_message, + } + finally: + session.close() + + +@router.post("/fetch-alt-titles") +def fetch_alt_titles_now(): + """Trigger a Wikidata alt-title fetch in the background (no file scan).""" + def _run(): + session = get_session() + try: + _fetch_alt_titles(session) + finally: + session.close() + threading.Thread(target=_run, daemon=True, name="alt-titles-fetch").start() + return {"status": "started"} + + +@router.get("/history") +def get_scan_history(): + session = get_session() + try: + records = ( + session.query(ScanHistory) + .order_by(ScanHistory.started_at.desc()) + .limit(20) + .all() + ) + return [ + { + "scan_id": r.id, + "type": r.scan_type, + "status": r.status, + "started_at": r.started_at.isoformat() if r.started_at else None, + "finished_at": r.finished_at.isoformat() if r.finished_at else None, + "files_found": r.files_found, + "files_added": r.files_added, + "files_removed": r.files_removed, + "files_unmatched": r.files_unmatched, + "error_message": r.error_message, + } + for r in records + ] + finally: + session.close() diff --git a/backend/routers/search.py b/backend/routers/search.py new file mode 100644 index 0000000..932e194 --- /dev/null +++ b/backend/routers/search.py @@ -0,0 +1,102 @@ +from fastapi import APIRouter, Query + +from database import Movie, NasSource, Season, Series, get_session +from metadata import _imdb_suggest + +router = APIRouter(prefix="/api/search", tags=["search"]) + +_IMDB_QID = {"movie": "movie", "series": "tvSeries"} + + +def _imdb_ids_for_query(q: str, kind: str) -> list[str]: + """Search IMDB with the user query and return matching IMDB IDs.""" + items = _imdb_suggest(q) + qid = _IMDB_QID.get(kind) + return [it["id"] for it in items if it.get("qid") == qid and it.get("id")] + + +def _movie_matches(session, pattern: str, q: str) -> list: + """Return movies matching by title in DB, OR via IMDB cross-reference.""" + # Direct title match in DB + by_title = session.query(Movie).filter(Movie.title.ilike(pattern)).all() + found_ids = {m.id for m in by_title} + + # Cross-language: search IMDB and look up matching IMDB IDs in our DB + imdb_ids = _imdb_ids_for_query(q, "movie") + if imdb_ids: + cross = session.query(Movie).filter( + Movie.imdb_id.in_(imdb_ids), + Movie.id.notin_(found_ids) if found_ids else Movie.id.isnot(None), + ).all() + by_title = by_title + cross + + return sorted(by_title, key=lambda m: m.title)[:50] + + +def _series_matches(session, pattern: str, q: str) -> list: + """Return series matching by title in DB, OR via IMDB cross-reference.""" + by_title = session.query(Series).filter(Series.title.ilike(pattern)).all() + found_ids = {s.id for s in by_title} + + imdb_ids = _imdb_ids_for_query(q, "series") + if imdb_ids: + cross = session.query(Series).filter( + Series.imdb_id.in_(imdb_ids), + Series.id.notin_(found_ids) if found_ids else Series.id.isnot(None), + ).all() + by_title = by_title + cross + + return sorted(by_title, key=lambda s: s.title)[:50] + + +@router.get("") +def search( + q: str = Query(..., min_length=1), + type: str = Query("all", pattern="^(all|movie|series)$"), +): + session = get_session() + try: + pattern = f"%{q}%" + results = {"movies": [], "series": []} + + if type in ("all", "movie"): + for m in _movie_matches(session, pattern, q): + nas = session.query(NasSource).get(m.nas_id) + results["movies"].append({ + "id": m.id, + "title": m.title, + "year": m.year, + "quality": m.quality, + "codec": m.codec, + "nas_name": nas.name if nas else None, + "file_path": m.file_path, + "file_size": m.file_size, + "poster_url": m.poster_url, + "imdb_id": m.imdb_id, + }) + + if type in ("all", "series"): + for s in _series_matches(session, pattern, q): + seasons_data = [] + for season in sorted(s.seasons, key=lambda x: (x.season_number, x.nas_id)): + nas = session.query(NasSource).get(season.nas_id) + seasons_data.append({ + "season_number": season.season_number, + "episodes_count": len(season.episodes), + "nas_name": nas.name if nas else None, + "nas_id": season.nas_id, + }) + results["series"].append({ + "id": s.id, + "title": s.title, + "year": s.year, + "seasons_count": len(s.seasons), + "seasons": seasons_data, + "poster_url": s.poster_url, + "imdb_id": s.imdb_id, + }) + + total = len(results["movies"]) + len(results["series"]) + return {"query": q, "results": results, "total": total} + finally: + session.close() diff --git a/backend/routers/stats.py b/backend/routers/stats.py new file mode 100644 index 0000000..dde14e6 --- /dev/null +++ b/backend/routers/stats.py @@ -0,0 +1,56 @@ +import os + +from fastapi import APIRouter + +from database import Episode, Movie, NasSource, ScanHistory, Season, Series, UnmatchedFile, get_session + +router = APIRouter(prefix="/api", tags=["stats"]) + + +@router.get("/stats") +def get_stats(): + session = get_session() + try: + movies_count = session.query(Movie).count() + series_count = session.query(Series).count() + episodes_count = session.query(Episode).count() + nas_count = session.query(NasSource).count() + unmatched_count = session.query(UnmatchedFile).count() + + last_scan = ( + session.query(ScanHistory) + .filter_by(status="completed") + .order_by(ScanHistory.finished_at.desc()) + .first() + ) + + return { + "movies": movies_count, + "series": series_count, + "episodes": episodes_count, + "nas_count": nas_count, + "unmatched_files": unmatched_count, + "last_scan": last_scan.finished_at.isoformat() if last_scan and last_scan.finished_at else None, + } + finally: + session.close() + + +@router.get("/nas") +def get_nas_list(): + session = get_session() + try: + sources = session.query(NasSource).all() + result = [] + for nas in sources: + accessible = os.path.isdir(nas.path) + result.append({ + "id": nas.id, + "name": nas.name, + "path": nas.path, + "accessible": accessible, + "last_scan": nas.last_scan.isoformat() if nas.last_scan else None, + }) + return result + finally: + session.close() diff --git a/backend/scanner.py b/backend/scanner.py new file mode 100644 index 0000000..bd43e98 --- /dev/null +++ b/backend/scanner.py @@ -0,0 +1,478 @@ +""" +scanner.py — Core media scanning logic. +Identifies movies and TV series from NAS volumes using parse-torrent-name. +""" + +import logging +import os +import re +import unicodedata +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime +from pathlib import Path +from typing import Optional + +import PTN + +from config import NasConfig, Settings +from database import ( + Episode, Movie, NasSource, ScanHistory, Season, Series, + UnmatchedFile, get_session, +) +from metadata import find_local_poster, get_movie_metadata, get_series_metadata + +logger = logging.getLogger(__name__) + +# Patterns that indicate a directory is a season folder +_SEASON_DIR_RE = re.compile(r"(?i)(?:season|saison|^s)\s*(\d+)$") +# Episode patterns in filename +_EPISODE_FILE_RE = re.compile(r"(?i)S\d{2}E\d{2}|\d{1,2}x\d{2}") +# Articles to strip during normalization +_LEADING_ARTICLES = re.compile(r"^(?:the|le|la|les|a|an)\s+", re.IGNORECASE) +# Characters to keep during normalization (letters, digits, space, hyphen) +_KEEP_CHARS = re.compile(r"[^\w\s\-]", re.UNICODE) + + +# ───────────────────────────────────────── +# Title normalization +# ───────────────────────────────────────── + +def normalize_title(title: str) -> str: + title = title.lower().strip() + title = unicodedata.normalize("NFKD", title) + title = title.encode("ascii", "ignore").decode("ascii") + title = _KEEP_CHARS.sub("", title) + title = _LEADING_ARTICLES.sub("", title) + title = re.sub(r"\s+", " ", title).strip() + return title + + +# ───────────────────────────────────────── +# Series vs Movie detection +# ───────────────────────────────────────── + +def _is_season_directory(path: Path) -> Optional[int]: + """Return season number if any parent dir indicates a season folder, else None.""" + for part in path.parts: + m = _SEASON_DIR_RE.search(part) + if m: + return int(m.group(1)) + return None + + +def classify_file(file_path: Path) -> tuple[bool, dict]: + """ + Parse the filename and classify as series (True) or movie (False). + Returns (is_series, ptn_info). + """ + info = PTN.parse(file_path.stem) + + # Priority 1: season directory name + season_from_dir = _is_season_directory(file_path) + if season_from_dir is not None: + if "season" not in info: + info["season"] = season_from_dir + return True, info + + # Priority 2: SxxExx pattern in filename + if _EPISODE_FILE_RE.search(file_path.stem): + return True, info + + # Priority 3: PTN returned both season and episode + if "season" in info and "episode" in info: + return True, info + + return False, info + + +# ───────────────────────────────────────── +# Database helpers +# ───────────────────────────────────────── + +def _upsert_nas_source(session, nas: NasConfig) -> NasSource: + record = session.query(NasSource).filter_by(path=nas.container_path).first() + if record is None: + record = NasSource(name=nas.name, path=nas.container_path) + session.add(record) + session.flush() + logger.info("Registered new NAS source: %s → %s", nas.name, nas.container_path) + else: + record.name = nas.name # update name if changed in .env + return record + + +def _get_or_create_series(session, title: str, year: Optional[int]) -> Series: + normalized = normalize_title(title) + record = session.query(Series).filter_by(title_normalized=normalized).first() + if record is None: + record = Series(title=title, title_normalized=normalized, year=year) + session.add(record) + session.flush() + return record + + +def _get_or_create_season(session, series: Series, season_number: int, nas_source: NasSource) -> Season: + record = ( + session.query(Season) + .filter_by(series_id=series.id, season_number=season_number, nas_id=nas_source.id) + .first() + ) + if record is None: + record = Season(series_id=series.id, season_number=season_number, nas_id=nas_source.id) + session.add(record) + session.flush() + return record + + +# ───────────────────────────────────────── +# File processing +# ───────────────────────────────────────── + +def _local_poster(file_path: Path) -> Optional[str]: + local = find_local_poster(file_path) + return f"local:{local}" if local else None + + +def _fetch_posters_parallel(session) -> None: + """ + Fetch missing posters and/or IMDB IDs in parallel (10 workers). + - Items without poster_url → fetch both poster + imdb_id + - Items with poster_url but without imdb_id → fetch imdb_id only + """ + from sqlalchemy import or_ + all_items = ( + [(m.id, "movie", m.title, m.year, m.poster_url is None) + for m in session.query(Movie).filter( + or_(Movie.poster_url.is_(None), Movie.imdb_id.is_(None)) + ).all()] + + [(s.id, "series", s.title, s.year, s.poster_url is None) + for s in session.query(Series).filter( + or_(Series.poster_url.is_(None), Series.imdb_id.is_(None)) + ).all()] + ) + if not all_items: + return + + logger.info("Fetching metadata for %d items in parallel…", len(all_items)) + + def fetch_one(task): + item_id, kind, title, year, need_poster = task + if kind == "movie": + meta = get_movie_metadata(title, year) + else: + meta = get_series_metadata(title, year) + return item_id, kind, meta, need_poster + + posters_fetched = 0 + with ThreadPoolExecutor(max_workers=10) as pool: + futures = {pool.submit(fetch_one, t): t for t in all_items} + for future in as_completed(futures): + try: + item_id, kind, meta, need_poster = future.result() + updates = {} + if need_poster and meta.poster_url: + updates["poster_url"] = meta.poster_url + posters_fetched += 1 + if meta.imdb_id: + updates["imdb_id"] = meta.imdb_id + if updates: + model = Movie if kind == "movie" else Series + session.query(model).filter_by(id=item_id).update(updates) + except Exception as exc: + logger.debug("Metadata fetch error: %s", exc) + + session.commit() + logger.info("Metadata pass done — new posters: %d, total processed: %d", + posters_fetched, len(all_items)) + + + +def _process_file( + session, + file_path: Path, + nas_source: NasSource, + settings: Settings, + counters: dict, + poster_cache: dict, +) -> None: + str_path = str(file_path) + + try: + file_size = file_path.stat().st_size + except OSError: + file_size = None + + try: + is_series, info = classify_file(file_path) + except Exception as exc: + reason = f"PTN parse error: {exc}" + logger.warning("Unmatched %s — %s", str_path, reason) + _record_unmatched(session, str_path, nas_source.id, reason) + counters["unmatched"] += 1 + return + + title = info.get("title") + if not title: + reason = "No title extracted by PTN" + _record_unmatched(session, str_path, nas_source.id, reason) + counters["unmatched"] += 1 + return + + year = info.get("year") + quality = info.get("quality") + codec = info.get("codec") + + # Fallback: extract year from title like "Black Bag (2025)" when PTN misses it + if not year: + m = re.search(r'\((\d{4})\)\s*$', title) + if m: + year = int(m.group(1)) + title = title[:m.start()].strip() + + if is_series: + season_num = info.get("season", 1) + episode_num = info.get("episode") + if episode_num is None: + reason = "Series detected but no episode number found" + _record_unmatched(session, str_path, nas_source.id, reason) + counters["unmatched"] += 1 + return + + series = _get_or_create_series(session, title, year) + + if series.poster_url is None: + local = _local_poster(file_path) + if local: + series.poster_url = local + + season = _get_or_create_season(session, series, int(season_num), nas_source) + + existing = session.query(Episode).filter_by(file_path=str_path).first() + if existing is None: + episode = Episode( + season_id=season.id, + episode_number=int(episode_num), + title=info.get("episode_title"), + quality=quality, + codec=codec, + file_path=str_path, + file_size=file_size, + ) + session.add(episode) + counters["added"] += 1 + else: + existing.quality = quality + existing.codec = codec + existing.file_size = file_size + existing.updated_at = datetime.utcnow() + else: + existing = session.query(Movie).filter_by(file_path=str_path).first() + if existing is None: + movie = Movie( + title=title, + year=year, + quality=quality, + codec=codec, + file_path=str_path, + file_size=file_size, + poster_url=_local_poster(file_path), + nas_id=nas_source.id, + ) + session.add(movie) + counters["added"] += 1 + else: + existing.title = title + existing.year = year + existing.quality = quality + existing.codec = codec + existing.file_size = file_size + existing.updated_at = datetime.utcnow() + if existing.poster_url is None: + local = _local_poster(file_path) + if local: + existing.poster_url = local + + counters["found"] += 1 + + +def _record_unmatched(session, file_path: str, nas_id: int, reason: str) -> None: + record = UnmatchedFile(file_path=file_path, nas_id=nas_id, reason=reason) + session.add(record) + + +# ───────────────────────────────────────── +# Staleness checks (removal of missing files) +# ───────────────────────────────────────── + +def _remove_stale_movies(session, nas_source: NasSource) -> int: + removed = 0 + movies = session.query(Movie).filter_by(nas_id=nas_source.id).all() + for movie in movies: + if not Path(movie.file_path).exists(): + logger.info("Removing stale movie: %s", movie.file_path) + session.delete(movie) + removed += 1 + return removed + + +def _remove_stale_episodes(session, nas_source: NasSource) -> int: + removed = 0 + seasons = session.query(Season).filter_by(nas_id=nas_source.id).all() + for season in seasons: + for episode in list(season.episodes): + if not Path(episode.file_path).exists(): + logger.info("Removing stale episode: %s", episode.file_path) + session.delete(episode) + removed += 1 + # Prune empty seasons + session.flush() + if not season.episodes: + # Re-query to check after flush + refreshed = session.query(Season).get(season.id) + if refreshed and not refreshed.episodes: + session.delete(refreshed) + # Prune series with no remaining seasons + for series in session.query(Series).all(): + session.flush() + refreshed = session.query(Series).get(series.id) + if refreshed and not refreshed.seasons: + session.delete(refreshed) + return removed + + +# ───────────────────────────────────────── +# Public scan functions +# ───────────────────────────────────────── + +def run_full_scan(settings: Settings, scan_id: int) -> None: + logger.info("Starting full scan (scan_id=%d)", scan_id) + session = get_session() + counters = {"found": 0, "added": 0, "removed": 0, "unmatched": 0} + poster_cache: dict = {} + + try: + # Clear unmatched from previous scans + session.query(UnmatchedFile).delete() + + for nas in settings.nas_list: + if not os.path.isdir(nas.container_path): + logger.warning("NAS path not accessible: %s — skipping", nas.container_path) + continue + + nas_source = _upsert_nas_source(session, nas) + session.commit() + + logger.info("Scanning NAS: %s (%s)", nas.name, nas.container_path) + + for root, _dirs, files in os.walk(nas.container_path): + for filename in files: + ext = Path(filename).suffix.lower() + if ext not in settings.video_extensions: + continue + file_path = Path(root) / filename + _process_file(session, file_path, nas_source, settings, counters, poster_cache) + + removed = _remove_stale_movies(session, nas_source) + removed += _remove_stale_episodes(session, nas_source) + counters["removed"] += removed + + nas_source.last_scan = datetime.utcnow() + session.commit() + + _fetch_posters_parallel(session) + _finish_scan(session, scan_id, "completed", counters) + logger.info("Full scan complete: %s", counters) + + except Exception as exc: + logger.exception("Full scan failed: %s", exc) + _finish_scan(session, scan_id, "failed", counters, str(exc)) + finally: + session.close() + + +def run_incremental_scan(settings: Settings, scan_id: int) -> None: + logger.info("Starting incremental scan (scan_id=%d)", scan_id) + session = get_session() + counters = {"found": 0, "added": 0, "removed": 0, "unmatched": 0} + poster_cache: dict = {} + + try: + for nas in settings.nas_list: + if not os.path.isdir(nas.container_path): + logger.warning("NAS path not accessible: %s — skipping", nas.container_path) + continue + + nas_source = _upsert_nas_source(session, nas) + session.commit() + + since = nas_source.last_scan + logger.info( + "Incremental scan NAS: %s — changes since %s", + nas.name, + since.isoformat() if since else "never", + ) + + since_ts = since.timestamp() if since else 0.0 + + for root, _dirs, files in os.walk(nas.container_path): + for filename in files: + ext = Path(filename).suffix.lower() + if ext not in settings.video_extensions: + continue + file_path = Path(root) / filename + try: + mtime = file_path.stat().st_mtime + except OSError: + continue + if mtime > since_ts: + _process_file(session, file_path, nas_source, settings, counters, poster_cache) + + removed = _remove_stale_movies(session, nas_source) + removed += _remove_stale_episodes(session, nas_source) + counters["removed"] += removed + + nas_source.last_scan = datetime.utcnow() + session.commit() + + _fetch_posters_parallel(session) + _finish_scan(session, scan_id, "completed", counters) + logger.info("Incremental scan complete: %s", counters) + + except Exception as exc: + logger.exception("Incremental scan failed: %s", exc) + _finish_scan(session, scan_id, "failed", counters, str(exc)) + finally: + session.close() + + +def _finish_scan(session, scan_id: int, status: str, counters: dict, error: str = None) -> None: + record = session.query(ScanHistory).get(scan_id) + if record: + record.status = status + record.finished_at = datetime.utcnow() + record.files_found = counters["found"] + record.files_added = counters["added"] + record.files_removed = counters["removed"] + record.files_unmatched = counters["unmatched"] + record.error_message = error + session.commit() + + +def create_scan_record(scan_type: str) -> int: + """Insert a new running scan record and return its ID.""" + session = get_session() + try: + record = ScanHistory(scan_type=scan_type, status="running", started_at=datetime.utcnow()) + session.add(record) + session.commit() + return record.id + finally: + session.close() + + +def has_any_scan_completed() -> bool: + session = get_session() + try: + return session.query(ScanHistory).filter_by(status="completed").count() > 0 + finally: + session.close() diff --git a/backend/scheduler.py b/backend/scheduler.py new file mode 100644 index 0000000..8f1799d --- /dev/null +++ b/backend/scheduler.py @@ -0,0 +1,95 @@ +""" +scheduler.py — Background scan scheduler using APScheduler. +""" + +import logging +import threading + +from apscheduler.schedulers.background import BackgroundScheduler + +from config import Settings +from scanner import ( + create_scan_record, + has_any_scan_completed, + run_full_scan, + run_incremental_scan, +) + +logger = logging.getLogger(__name__) + +_scheduler: BackgroundScheduler | None = None +_scan_lock = threading.Lock() +_active_scan_id: int | None = None + + +def get_active_scan_id() -> int | None: + return _active_scan_id + + +def is_scan_running() -> bool: + return _active_scan_id is not None + + +def _run_scan_protected(scan_type: str, settings: Settings) -> int | None: + """Try to acquire lock and run a scan. Returns scan_id or None if already running.""" + global _active_scan_id + if not _scan_lock.acquire(blocking=False): + logger.warning("Scan already in progress — skipping %s scan request", scan_type) + return None + + scan_id = create_scan_record(scan_type) + _active_scan_id = scan_id + logger.info("Scan %s started (id=%d)", scan_type, scan_id) + + def _run(): + global _active_scan_id + try: + if scan_type == "full": + run_full_scan(settings, scan_id) + else: + run_incremental_scan(settings, scan_id) + finally: + _active_scan_id = None + _scan_lock.release() + logger.info("Scan %s finished (id=%d)", scan_type, scan_id) + + thread = threading.Thread(target=_run, daemon=True, name=f"scan-{scan_type}-{scan_id}") + thread.start() + return scan_id + + +def trigger_full_scan(settings: Settings) -> int | None: + return _run_scan_protected("full", settings) + + +def trigger_incremental_scan(settings: Settings) -> int | None: + return _run_scan_protected("incremental", settings) + + +def start_scheduler(settings: Settings) -> None: + global _scheduler + + _scheduler = BackgroundScheduler(timezone="UTC") + _scheduler.add_job( + func=lambda: trigger_incremental_scan(settings), + trigger="interval", + hours=settings.scan_interval_hours, + id="incremental_scan", + replace_existing=True, + ) + _scheduler.start() + logger.info( + "Scheduler started — incremental scan every %d hour(s)", + settings.scan_interval_hours, + ) + + # Trigger initial full scan if the database has never been scanned + if not has_any_scan_completed(): + logger.info("No prior completed scan found — launching initial full scan") + trigger_full_scan(settings) + + +def stop_scheduler() -> None: + if _scheduler and _scheduler.running: + _scheduler.shutdown(wait=False) + logger.info("Scheduler stopped") diff --git a/db/init.sql b/db/init.sql new file mode 100644 index 0000000..1473a23 --- /dev/null +++ b/db/init.sql @@ -0,0 +1,119 @@ +-- MediaTracker database schema +-- MariaDB 11 / utf8mb4 + +CREATE DATABASE IF NOT EXISTS mediatracker + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; + +USE mediatracker; + +-- ───────────────────────────────────────── +-- NAS sources +-- ───────────────────────────────────────── +CREATE TABLE IF NOT EXISTS nas_sources ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(255) NOT NULL, + path VARCHAR(1024) NOT NULL, + last_scan DATETIME NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_nas_path (path(512)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ───────────────────────────────────────── +-- Movies +-- ───────────────────────────────────────── +CREATE TABLE IF NOT EXISTS movies ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + title VARCHAR(512) NOT NULL, + year SMALLINT NULL, + quality VARCHAR(64) NULL, + codec VARCHAR(64) NULL, + file_path VARCHAR(2048) NOT NULL, + file_size BIGINT UNSIGNED NULL, + nas_id INT UNSIGNED NOT NULL, + added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_movie_path (file_path(512)), + INDEX idx_movie_title (title(255)), + CONSTRAINT fk_movie_nas FOREIGN KEY (nas_id) REFERENCES nas_sources (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ───────────────────────────────────────── +-- Series +-- ───────────────────────────────────────── +CREATE TABLE IF NOT EXISTS series ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + title VARCHAR(512) NOT NULL, + title_normalized VARCHAR(512) NOT NULL, + year SMALLINT NULL, + added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_series_normalized (title_normalized(255)), + INDEX idx_series_title (title(255)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ───────────────────────────────────────── +-- Seasons +-- ───────────────────────────────────────── +CREATE TABLE IF NOT EXISTS seasons ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + series_id INT UNSIGNED NOT NULL, + season_number SMALLINT NOT NULL, + nas_id INT UNSIGNED NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_season (series_id, season_number, nas_id), + CONSTRAINT fk_season_series FOREIGN KEY (series_id) REFERENCES series (id) ON DELETE CASCADE, + CONSTRAINT fk_season_nas FOREIGN KEY (nas_id) REFERENCES nas_sources (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ───────────────────────────────────────── +-- Episodes +-- ───────────────────────────────────────── +CREATE TABLE IF NOT EXISTS episodes ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + season_id INT UNSIGNED NOT NULL, + episode_number SMALLINT NOT NULL, + title VARCHAR(512) NULL, + quality VARCHAR(64) NULL, + codec VARCHAR(64) NULL, + file_path VARCHAR(2048) NOT NULL, + file_size BIGINT UNSIGNED NULL, + added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_episode_path (file_path(512)), + CONSTRAINT fk_episode_season FOREIGN KEY (season_id) REFERENCES seasons (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ───────────────────────────────────────── +-- Unmatched files +-- ───────────────────────────────────────── +CREATE TABLE IF NOT EXISTS unmatched_files ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + file_path VARCHAR(2048) NOT NULL, + nas_id INT UNSIGNED NOT NULL, + reason VARCHAR(512) NOT NULL, + scanned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + INDEX idx_unmatched_nas (nas_id), + CONSTRAINT fk_unmatched_nas FOREIGN KEY (nas_id) REFERENCES nas_sources (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ───────────────────────────────────────── +-- Scan history +-- ───────────────────────────────────────── +CREATE TABLE IF NOT EXISTS scan_history ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + scan_type ENUM('full','incremental') NOT NULL, + status ENUM('running','completed','failed') NOT NULL DEFAULT 'running', + started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + finished_at DATETIME NULL, + files_found INT UNSIGNED NOT NULL DEFAULT 0, + files_added INT UNSIGNED NOT NULL DEFAULT 0, + files_removed INT UNSIGNED NOT NULL DEFAULT 0, + files_unmatched INT UNSIGNED NOT NULL DEFAULT 0, + error_message TEXT NULL, + PRIMARY KEY (id), + INDEX idx_scan_started (started_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3c29085 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,50 @@ +services: + db: + image: mariadb:11 + restart: unless-stopped + environment: + MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + MARIADB_DATABASE: ${DB_NAME} + MARIADB_USER: ${DB_USER} + MARIADB_PASSWORD: ${DB_PASSWORD} + volumes: + - db_data:/var/lib/mysql + - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + + backend: + build: ./backend + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + DB_HOST: db + DB_PORT: 3306 + DB_NAME: ${DB_NAME} + DB_USER: ${DB_USER} + DB_PASSWORD: ${DB_PASSWORD} + SCAN_INTERVAL_HOURS: ${SCAN_INTERVAL_HOURS:-6} + VIDEO_EXTENSIONS: ${VIDEO_EXTENSIONS:-.mkv,.mp4,.avi,.m4v,.mov,.wmv,.ts,.m2ts,.iso} + TZ: ${TZ:-Europe/Paris} + volumes: + - /nas # placeholder — overridden by docker-compose.override.yml + + frontend: + image: nginx:alpine + restart: unless-stopped + depends_on: + - backend + volumes: + - ./frontend:/usr/share/nginx/html:ro + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + ports: + - "${APP_PORT:-8080}:80" + +volumes: + db_data: diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..92e6a93 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,711 @@ + + + + + + MediaTracker + + + + + + + +
+ +
+ films + séries + épisodes + Chargement… +
+
+ +
+
+
+
+ + +
+
+ + + +
+
+
+ +
+
+
+
+ +
+
+
Films
+
Séries
+
Épisodes
+
NAS
+
+ +
+
+
Sources NAS
+
Chargement…
+
+
+
Synchronisation
+
+ + +
+
+
+ + +
+
+
+
Historique
+
Aucun scan effectué
+
+
+
+
+ + + + + + + + + diff --git a/generate-compose.py b/generate-compose.py new file mode 100644 index 0000000..c494fe9 --- /dev/null +++ b/generate-compose.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +""" +generate-compose.py +Reads .env and generates docker-compose.override.yml with NAS volumes. +No external dependencies required. +""" + +import os +import sys +import re +from pathlib import Path + +ENV_FILE = Path(".env") +OUTPUT_FILE = Path("docker-compose.override.yml") + +OVERRIDE_HEADER = """\ +# Auto-generated by generate-compose.py — do not edit manually. +# Re-run the script after changing NAS_* entries in .env. + +services: + backend: + volumes: +""" + + +def load_env(path: Path) -> dict: + env = {} + if not path.exists(): + print(f"[ERROR] {path} not found. Copy .env.example to .env first.") + sys.exit(1) + with path.open(encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + # Strip inline comments + value = value.split("#")[0].strip() + env[key] = value + return env + + +def parse_nas_entries(env: dict) -> list[tuple[str, str, str]]: + """Return list of (key, name, host_path) sorted by key.""" + nas_pattern = re.compile(r"^NAS_(\d+)$") + entries = [] + for key, value in env.items(): + match = nas_pattern.match(key) + if not match: + continue + if "|" not in value: + print(f"[WARN] {key}={value!r} — expected format 'Name|/host/path', skipping.") + continue + name, _, host_path = value.partition("|") + entries.append((key, name.strip(), host_path.strip())) + entries.sort(key=lambda t: int(t[0].split("_")[1])) + return entries + + +def validate_paths(entries: list[tuple[str, str, str]]) -> None: + for key, name, host_path in entries: + if not Path(host_path).exists(): + print(f"[WARN] {key} — host path does not exist: {host_path!r}") + else: + print(f"[OK] {key} — {name!r} → {host_path}") + + +def generate_override(entries: list[tuple[str, str, str]]) -> str: + lines = [OVERRIDE_HEADER] + for idx, (key, name, host_path) in enumerate(entries, start=1): + container_path = f"/nas/nas{idx}" + lines.append(f" - {host_path}:{container_path}:ro # {name}") + # Pass NAS_X env vars into the container so config.py can read the names + lines.append(" environment:") + for idx, (key, name, host_path) in enumerate(entries, start=1): + lines.append(f" - {key}={name}|/nas/nas{idx}") + lines.append("") + return "\n".join(lines) + + +def main() -> None: + print("=" * 55) + print(" MediaTracker — generate-compose.py") + print("=" * 55) + + env = load_env(ENV_FILE) + entries = parse_nas_entries(env) + + if not entries: + print("[ERROR] No NAS_* entries found in .env. Add at least one NAS.") + sys.exit(1) + + print(f"\nFound {len(entries)} NAS source(s):\n") + validate_paths(entries) + + content = generate_override(entries) + OUTPUT_FILE.write_text(content, encoding="utf-8") + + print(f"\n[DONE] {OUTPUT_FILE} generated with {len(entries)} volume(s).") + print("\nNext step:") + print(" docker compose -f docker-compose.yml -f docker-compose.override.yml up -d --build\n") + + +if __name__ == "__main__": + main() diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..26b68fc --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,52 @@ +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent"'; + access_log /var/log/nginx/access.log main; + + sendfile on; + keepalive_timeout 65; + + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml; + gzip_min_length 1024; + + server { + listen 80; + server_name _; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Proxy API requests to the backend + location /api/ { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 120s; + } + + # Serve static frontend files + location / { + root /usr/share/nginx/html; + index index.html; + try_files $uri $uri/ /index.html; + } + } +}