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 <noreply@anthropic.com>
This commit is contained in:
snoopy
2026-05-19 11:40:44 +00:00
commit 6f551ba439
21 changed files with 2770 additions and 0 deletions

30
.env.example Normal file
View File

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

32
.gitignore vendored Normal file
View File

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

21
LICENSE Normal file
View File

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

225
README.md Normal file
View File

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

18
backend/Dockerfile Normal file
View File

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

71
backend/config.py Normal file
View File

@@ -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(),
)

194
backend/database.py Normal file
View File

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

125
backend/main.py Normal file
View File

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

178
backend/metadata.py Normal file
View File

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

7
backend/requirements.txt Normal file
View File

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

View File

98
backend/routers/scan.py Normal file
View File

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

102
backend/routers/search.py Normal file
View File

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

56
backend/routers/stats.py Normal file
View File

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

478
backend/scanner.py Normal file
View File

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

95
backend/scheduler.py Normal file
View File

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

119
db/init.sql Normal file
View File

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

50
docker-compose.yml Normal file
View File

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

711
frontend/index.html Normal file
View File

@@ -0,0 +1,711 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MediaTracker</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=DM+Sans:wght@300;400;500;600&display=swap" rel="stylesheet" />
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f0f0f;
--surface: #1a1a1a;
--surface2: #222222;
--border: #2e2e2e;
--accent: #e50914;
--accent-dim:#9e0a0f;
--text: #f0f0f0;
--text-muted:#888;
--green: #22c55e;
--red: #ef4444;
--radius: 10px;
--poster-w: 90px;
--poster-h: 135px;
}
html, body {
background: var(--bg);
color: var(--text);
font-family: 'DM Sans', sans-serif;
font-size: 15px;
min-height: 100vh;
}
/* ── HEADER ── */
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 32px;
border-bottom: 1px solid var(--border);
background: var(--surface);
position: sticky;
top: 0;
z-index: 100;
}
.logo { font-family: 'Bebas Neue', sans-serif; font-size: 2rem; letter-spacing: 2px; color: var(--accent); }
.header-stats { display: flex; gap: 24px; font-size: 0.8rem; color: var(--text-muted); }
.header-stats span strong { color: var(--text); }
/* ── MAIN ── */
main { max-width: 1100px; margin: 0 auto; padding: 40px 24px 80px; }
/* ── SEARCH ── */
.search-section { margin-bottom: 40px; }
.search-row { display: flex; gap: 12px; align-items: center; }
.search-input-wrap { flex: 1; position: relative; }
.search-input-wrap svg { position: absolute; left: 16px; top: 50%; transform: translateY(-50%); color: var(--text-muted); pointer-events: none; }
#searchInput {
width: 100%; padding: 14px 16px 14px 48px;
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
color: var(--text); font-family: 'DM Sans', sans-serif; font-size: 1rem;
transition: border-color .2s; outline: none;
}
#searchInput:focus { border-color: var(--accent); }
#searchInput::placeholder { color: var(--text-muted); }
.type-filter { display: flex; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
.type-filter button {
padding: 10px 18px; background: var(--surface); border: none;
color: var(--text-muted); font-family: 'DM Sans', sans-serif; font-size: 0.85rem;
cursor: pointer; transition: background .15s, color .15s;
}
.type-filter button:not(:last-child) { border-right: 1px solid var(--border); }
.type-filter button.active { background: var(--accent); color: #fff; }
/* ── RESULTS ── */
#resultsSection { display: none; }
.results-header { font-size: 0.8rem; color: var(--text-muted); margin-bottom: 20px; }
.results-header strong { color: var(--text); }
.section-label { font-family: 'Bebas Neue', sans-serif; font-size: 1.3rem; letter-spacing: 1px; margin-bottom: 14px; color: var(--text-muted); }
/* ── MEDIA CARDS (poster layout) ── */
.media-list { display: flex; flex-direction: column; gap: 10px; margin-bottom: 36px; }
.media-card {
display: flex;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
cursor: pointer;
transition: border-color .2s, transform .15s, box-shadow .2s;
}
.media-card:hover {
border-color: var(--accent);
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(229,9,20,.12);
}
/* Poster column */
.card-poster {
width: var(--poster-w);
min-width: var(--poster-w);
height: var(--poster-h);
position: relative;
background: var(--surface2);
flex-shrink: 0;
}
.card-poster img {
width: 100%; height: 100%;
object-fit: cover;
display: block;
}
.card-poster .no-poster {
width: 100%; height: 100%;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 6px; color: var(--border);
}
.card-poster .no-poster svg { opacity: .5; }
.card-poster .no-poster span { font-size: 0.6rem; opacity: .4; letter-spacing: .5px; }
/* Info column */
.card-info {
flex: 1;
padding: 14px 16px;
display: flex;
flex-direction: column;
justify-content: center;
min-width: 0;
}
.card-title {
font-size: 0.95rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
}
.card-year { font-size: 0.78rem; color: var(--text-muted); margin-bottom: 8px; }
.card-meta { display: flex; flex-wrap: wrap; gap: 5px; }
.badge {
font-size: 0.7rem; font-weight: 500;
padding: 2px 7px; border-radius: 4px;
background: var(--surface2); color: var(--text-muted); border: 1px solid var(--border);
}
.badge.accent { background: var(--accent-dim); color: #fff; border-color: var(--accent); }
.badge.nas { background: #1e2a3a; color: #60a5fa; border-color: #2c4060; }
.badge.series { background: #1a2e1a; color: #4ade80; border-color: #2a4a2a; }
/* ── MODAL ── */
#modal {
display: none;
position: fixed; inset: 0; z-index: 200;
background: rgba(0,0,0,.75);
align-items: center; justify-content: center;
padding: 24px;
}
#modal.open { display: flex; }
.modal-box {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
max-width: 680px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
position: relative;
}
.modal-close {
position: absolute; top: 14px; right: 14px;
background: var(--surface2); border: 1px solid var(--border);
border-radius: 6px; color: var(--text-muted);
cursor: pointer; padding: 4px 8px; font-size: 1.1rem; line-height: 1;
transition: color .15s;
}
.modal-close:hover { color: var(--text); }
.modal-hero {
display: flex; gap: 24px; padding: 24px;
border-bottom: 1px solid var(--border);
}
.modal-poster {
width: 140px; min-width: 140px; height: 210px;
border-radius: 8px; overflow: hidden;
background: var(--surface2);
flex-shrink: 0;
}
.modal-poster img { width: 100%; height: 100%; object-fit: cover; display: block; }
.modal-poster .no-poster {
width: 100%; height: 100%;
display: flex; flex-direction: column; align-items: center; justify-content: center;
color: var(--border); gap: 8px;
}
.modal-poster .no-poster svg { opacity: .4; }
.modal-info { flex: 1; }
.modal-title { font-family: 'Bebas Neue', sans-serif; font-size: 1.8rem; letter-spacing: 1px; margin-bottom: 4px; }
.modal-year { font-size: 0.85rem; color: var(--text-muted); margin-bottom: 14px; }
.modal-badges { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
.modal-path { font-size: 0.72rem; color: var(--text-muted); word-break: break-all; margin-top: 10px; }
.modal-size { font-size: 0.75rem; color: var(--text-muted); margin-top: 4px; }
.modal-seasons { padding: 20px 24px; }
.modal-seasons-title { font-family: 'Bebas Neue', sans-serif; font-size: 1.1rem; letter-spacing: 1px; color: var(--text-muted); margin-bottom: 12px; }
.seasons-grid { display: flex; flex-wrap: wrap; gap: 8px; }
.season-chip {
background: var(--surface2); border: 1px solid var(--border); border-radius: 6px;
padding: 6px 12px; font-size: 0.78rem; color: var(--text-muted);
}
.season-chip strong { color: var(--text); }
/* ── DASHBOARD ── */
.stats-grid {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 32px;
}
.stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; text-align: center; }
.stat-card .stat-number { font-family: 'Bebas Neue', sans-serif; font-size: 2.5rem; color: var(--accent); line-height: 1; }
.stat-card .stat-label { font-size: 0.78rem; color: var(--text-muted); margin-top: 4px; text-transform: uppercase; letter-spacing: .5px; }
.dashboard-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
.panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; }
.panel-title { font-family: 'Bebas Neue', sans-serif; font-size: 1.1rem; letter-spacing: 1px; margin-bottom: 16px; color: var(--text-muted); }
.nas-list { display: flex; flex-direction: column; gap: 10px; }
.nas-item { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; background: var(--surface2); border-radius: 6px; border: 1px solid var(--border); }
.nas-name { font-weight: 500; font-size: 0.9rem; }
.nas-path { font-size: 0.72rem; color: var(--text-muted); margin-top: 2px; }
.nas-last-scan { font-size: 0.7rem; color: var(--text-muted); }
.status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.status-dot.ok { background: var(--green); box-shadow: 0 0 6px var(--green); }
.status-dot.err { background: var(--red); box-shadow: 0 0 6px var(--red); }
.scan-btns { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.btn { padding: 10px 20px; border: none; border-radius: 6px; font-family: 'DM Sans', sans-serif; font-size: 0.85rem; font-weight: 500; cursor: pointer; transition: background .15s, opacity .15s; }
.btn:disabled { opacity: .4; cursor: not-allowed; }
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:not(:disabled):hover { background: #c4070f; }
.btn-secondary { background: var(--surface2); color: var(--text); border: 1px solid var(--border); }
.btn-secondary:not(:disabled):hover { border-color: var(--accent); color: var(--accent); }
.scan-status-bar { background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; padding: 12px 14px; font-size: 0.8rem; color: var(--text-muted); margin-bottom: 16px; display: none; }
.scan-status-bar.visible { display: block; }
.scan-status-bar .status-line { display: flex; justify-content: space-between; margin-bottom: 6px; }
.scan-progress-track { height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; }
.scan-progress-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width .4s; }
.scan-history { display: flex; flex-direction: column; gap: 8px; }
.scan-history-item { display: flex; justify-content: space-between; align-items: flex-start; padding: 8px 12px; background: var(--surface2); border-radius: 6px; font-size: 0.78rem; border: 1px solid var(--border); }
.scan-history-item .sh-left { color: var(--text); }
.scan-history-item .sh-right { color: var(--text-muted); text-align: right; }
.sh-status { display: inline-block; padding: 1px 7px; border-radius: 3px; font-size: 0.68rem; font-weight: 600; text-transform: uppercase; letter-spacing: .3px; }
.sh-status.completed { background: #14532d; color: #4ade80; }
.sh-status.running { background: #1e3a5f; color: #60a5fa; }
.sh-status.failed { background: #450a0a; color: #f87171; }
#syncFab {
position: fixed; bottom: 28px; right: 28px;
background: var(--accent); color: #fff; border: none; border-radius: 50px;
padding: 12px 22px; font-family: 'DM Sans', sans-serif; font-size: 0.85rem; font-weight: 600;
cursor: pointer; box-shadow: 0 4px 20px rgba(229,9,20,.4);
transition: background .15s, transform .15s; z-index: 50;
}
#syncFab:hover { background: #c4070f; transform: scale(1.04); }
#syncFab.scanning { background: var(--accent-dim); animation: pulse 1.5s infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.6} }
.empty-state { text-align: center; color: var(--text-muted); padding: 40px 0; font-size: 0.9rem; }
.last-sync { font-size: 0.75rem; color: var(--text-muted); margin-top: 12px; }
@media (max-width: 700px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.dashboard-columns { grid-template-columns: 1fr; }
header { flex-direction: column; gap: 12px; align-items: flex-start; }
.search-row { flex-direction: column; align-items: stretch; }
.modal-hero { flex-direction: column; }
.modal-poster { width: 100%; height: 200px; }
}
</style>
</head>
<body>
<header>
<div class="logo">MediaTracker</div>
<div class="header-stats">
<span><strong id="hMovies"></strong> films</span>
<span><strong id="hSeries"></strong> séries</span>
<span><strong id="hEpisodes"></strong> épisodes</span>
<span class="last-sync" id="hLastSync">Chargement…</span>
</div>
</header>
<main>
<section class="search-section">
<div class="search-row">
<div class="search-input-wrap">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
<input type="text" id="searchInput" placeholder="Rechercher un film, une série…" autocomplete="off" />
</div>
<div class="type-filter" id="typeFilter">
<button class="active" data-type="all">Tout</button>
<button data-type="movie">Films</button>
<button data-type="series">Séries</button>
</div>
</div>
</section>
<section id="resultsSection">
<div class="results-header" id="resultsHeader"></div>
<div id="resultsContainer"></div>
</section>
<section id="dashboard">
<div class="stats-grid">
<div class="stat-card"><div class="stat-number" id="statMovies"></div><div class="stat-label">Films</div></div>
<div class="stat-card"><div class="stat-number" id="statSeries"></div><div class="stat-label">Séries</div></div>
<div class="stat-card"><div class="stat-number" id="statEpisodes"></div><div class="stat-label">Épisodes</div></div>
<div class="stat-card"><div class="stat-number" id="statNas"></div><div class="stat-label">NAS</div></div>
</div>
<div class="dashboard-columns">
<div class="panel">
<div class="panel-title">Sources NAS</div>
<div class="nas-list" id="nasList"><div class="empty-state">Chargement…</div></div>
</div>
<div class="panel">
<div class="panel-title">Synchronisation</div>
<div class="scan-btns">
<button class="btn btn-secondary" id="btnIncremental" onclick="startScan('incremental')">Scan incrémental</button>
<button class="btn btn-primary" id="btnFull" onclick="confirmFullScan()">Scan complet</button>
</div>
<div class="scan-status-bar" id="scanStatusBar">
<div class="status-line">
<span id="scanStatusText"></span>
<span id="scanStatusType"></span>
</div>
<div class="scan-progress-track"><div class="scan-progress-fill" id="scanProgressFill" style="width:0%"></div></div>
</div>
<div class="panel-title" style="margin-top:20px">Historique</div>
<div class="scan-history" id="scanHistory"><div class="empty-state">Aucun scan effectué</div></div>
</div>
</div>
</section>
</main>
<!-- Modal détail -->
<div id="modal">
<div class="modal-box" id="modalBox">
<button class="modal-close" onclick="closeModal()"></button>
<div class="modal-hero">
<div class="modal-poster" id="modalPoster"></div>
<div class="modal-info">
<div class="modal-title" id="modalTitle"></div>
<div class="modal-year" id="modalYear"></div>
<div class="modal-badges" id="modalBadges"></div>
<div class="modal-path" id="modalPath"></div>
<div class="modal-size" id="modalSize"></div>
</div>
</div>
<div class="modal-seasons" id="modalSeasons" style="display:none">
<div class="modal-seasons-title">Saisons</div>
<div class="seasons-grid" id="modalSeasonsGrid"></div>
</div>
</div>
</div>
<button id="syncFab" onclick="startScan('incremental')" title="Scan incrémental rapide">Synchroniser</button>
<script>
'use strict';
let searchType = 'all';
let debounceTimer = null;
let pollInterval = null;
// ─── Init ────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
loadStats();
loadNas();
loadScanHistory();
checkActiveScans();
document.getElementById('searchInput').addEventListener('input', onSearchInput);
document.querySelectorAll('#typeFilter button').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('#typeFilter button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
searchType = btn.dataset.type;
const q = document.getElementById('searchInput').value.trim();
if (q.length > 0) performSearch(q);
});
});
// Close modal on backdrop click
document.getElementById('modal').addEventListener('click', e => {
if (e.target === document.getElementById('modal')) closeModal();
});
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); });
});
// ─── Helpers ─────────────────────────────
async function api(path) {
const res = await fetch(path);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
async function apiPost(path) {
const res = await fetch(path, { method: 'POST' });
if (!res.ok) { const b = await res.json().catch(()=>({})); throw new Error(b.detail || `HTTP ${res.status}`); }
return res.json();
}
function fmtDate(iso) {
if (!iso) return '—';
const d = new Date(iso);
return d.toLocaleDateString('fr-FR',{day:'2-digit',month:'2-digit',year:'numeric'}) + ' ' +
d.toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'});
}
function fmtSize(bytes) {
if (!bytes) return '';
const gb = bytes / 1e9;
return gb >= 1 ? gb.toFixed(1) + ' Go' : (bytes/1e6).toFixed(0) + ' Mo';
}
function escHtml(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ─── Poster helpers ───────────────────────
const POSTER_PLACEHOLDER = `
<div class="no-poster">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="2" width="20" height="20" rx="3"/>
<path d="m2 12 5-5 4 4 3-3 8 8"/>
<circle cx="8" cy="7" r="1.5"/>
</svg>
<span>SANS JAQUETTE</span>
</div>`;
function resolvePostUrl(raw) {
if (!raw) return null;
if (raw.startsWith('local:')) return '/api/local-poster?path=' + encodeURIComponent(raw.slice(6));
return raw;
}
function showPlaceholder(el) {
el.innerHTML = POSTER_PLACEHOLDER;
}
function posterHtml(url, cls='card-poster') {
const src = resolvePostUrl(url);
if (src) {
return `<div class="${cls}"><img src="${escHtml(src)}" alt="" loading="lazy" onerror="showPlaceholder(this.parentElement)" /></div>`;
}
return `<div class="${cls}">${POSTER_PLACEHOLDER}</div>`;
}
// ─── Stats ───────────────────────────────
async function loadStats() {
try {
const s = await api('/api/stats');
document.getElementById('statMovies').textContent = s.movies.toLocaleString('fr');
document.getElementById('statSeries').textContent = s.series.toLocaleString('fr');
document.getElementById('statEpisodes').textContent = s.episodes.toLocaleString('fr');
document.getElementById('statNas').textContent = s.nas_count;
document.getElementById('hMovies').textContent = s.movies.toLocaleString('fr');
document.getElementById('hSeries').textContent = s.series.toLocaleString('fr');
document.getElementById('hEpisodes').textContent = s.episodes.toLocaleString('fr');
document.getElementById('hLastSync').textContent = s.last_scan ? 'Sync ' + fmtDate(s.last_scan) : 'Jamais synchronisé';
} catch(e) { console.error('Stats error:', e); }
}
// ─── NAS ─────────────────────────────────
async function loadNas() {
try {
const list = await api('/api/nas');
const el = document.getElementById('nasList');
if (!list.length) { el.innerHTML = '<div class="empty-state">Aucun NAS configuré</div>'; return; }
el.innerHTML = list.map(n => `
<div class="nas-item">
<div>
<div class="nas-name">${escHtml(n.name)}</div>
<div class="nas-path">${escHtml(n.path)}</div>
<div class="nas-last-scan">Dernier scan : ${fmtDate(n.last_scan)}</div>
</div>
<div class="status-dot ${n.accessible?'ok':'err'}" title="${n.accessible?'Accessible':'Inaccessible'}"></div>
</div>`).join('');
} catch(e) { document.getElementById('nasList').innerHTML='<div class="empty-state">Erreur de chargement</div>'; }
}
// ─── Search ──────────────────────────────
function onSearchInput(e) {
const q = e.target.value.trim();
clearTimeout(debounceTimer);
if (q.length === 0) { showDashboard(); return; }
debounceTimer = setTimeout(() => performSearch(q), 300);
}
async function performSearch(q) {
try {
const data = await api(`/api/search?q=${encodeURIComponent(q)}&type=${searchType}`);
renderResults(data, q);
} catch(e) {
document.getElementById('resultsContainer').innerHTML =
`<div class="empty-state">Erreur lors de la recherche : ${escHtml(e.message)}</div>`;
}
}
function renderResults(data, q) {
document.getElementById('dashboard').style.display = 'none';
document.getElementById('resultsSection').style.display = 'block';
document.getElementById('resultsHeader').innerHTML =
`<strong>${data.total}</strong> résultat${data.total!==1?'s':''} pour « ${escHtml(q)} »`;
let html = '';
if (data.results.movies.length) {
html += `<div class="section-label">Films (${data.results.movies.length})</div><div class="media-list">`;
html += data.results.movies.map((m,i) => `
<div class="media-card" onclick="openMovieModal(${i})" data-idx="${i}">
${posterHtml(m.poster_url)}
<div class="card-info">
<div class="card-title" title="${escHtml(m.title)}">${escHtml(m.title)}</div>
${m.year ? `<div class="card-year">${m.year}</div>` : '<div class="card-year">Année inconnue</div>'}
<div class="card-meta">
${m.quality ? `<span class="badge accent">${escHtml(m.quality)}</span>` : ''}
${m.codec ? `<span class="badge">${escHtml(m.codec)}</span>` : ''}
${m.nas_name? `<span class="badge nas">${escHtml(m.nas_name)}</span>` : ''}
${m.file_size ? `<span class="badge">${fmtSize(m.file_size)}</span>` : ''}
</div>
</div>
</div>`).join('');
html += '</div>';
}
if (data.results.series.length) {
html += `<div class="section-label">Séries (${data.results.series.length})</div><div class="media-list">`;
html += data.results.series.map((s,i) => `
<div class="media-card" onclick="openSeriesModal(${i})">
${posterHtml(s.poster_url)}
<div class="card-info">
<div class="card-title" title="${escHtml(s.title)}">${escHtml(s.title)}</div>
${s.year ? `<div class="card-year">${s.year}</div>` : '<div class="card-year">Année inconnue</div>'}
<div class="card-meta">
<span class="badge series">${s.seasons_count} saison${s.seasons_count!==1?'s':''}</span>
${s.seasons.map(sn=>`<span class="badge">S${String(sn.season_number).padStart(2,'0')} · ${sn.episodes_count} ép.</span>`).join('')}
</div>
</div>
</div>`).join('');
html += '</div>';
}
if (!html) html = '<div class="empty-state">Aucun résultat trouvé</div>';
document.getElementById('resultsContainer').innerHTML = html;
// Store results for modal access
window._lastMovies = data.results.movies;
window._lastSeries = data.results.series;
}
function showDashboard() {
document.getElementById('resultsSection').style.display = 'none';
document.getElementById('dashboard').style.display = 'block';
}
// ─── Modal ───────────────────────────────
function openMovieModal(idx) {
const m = window._lastMovies[idx];
if (!m) return;
const mSrc = resolvePostUrl(m.poster_url);
document.getElementById('modalPoster').innerHTML = mSrc
? `<img src="${escHtml(mSrc)}" alt="" onerror="showPlaceholder(this.parentElement)" />`
: POSTER_PLACEHOLDER;
document.getElementById('modalTitle').textContent = m.title;
document.getElementById('modalYear').textContent = m.year || 'Année inconnue';
document.getElementById('modalPath').textContent = m.file_path;
document.getElementById('modalSize').textContent = m.file_size ? fmtSize(m.file_size) : '';
const badges = [
m.quality ? `<span class="badge accent">${escHtml(m.quality)}</span>` : '',
m.codec ? `<span class="badge">${escHtml(m.codec)}</span>` : '',
m.nas_name ? `<span class="badge nas">${escHtml(m.nas_name)}</span>` : '',
].filter(Boolean).join('');
document.getElementById('modalBadges').innerHTML = badges;
document.getElementById('modalSeasons').style.display = 'none';
document.getElementById('modal').classList.add('open');
document.body.style.overflow = 'hidden';
}
function openSeriesModal(idx) {
const s = window._lastSeries[idx];
if (!s) return;
const sSrc = resolvePostUrl(s.poster_url);
document.getElementById('modalPoster').innerHTML = sSrc
? `<img src="${escHtml(sSrc)}" alt="" onerror="showPlaceholder(this.parentElement)" />`
: POSTER_PLACEHOLDER;
document.getElementById('modalTitle').textContent = s.title;
document.getElementById('modalYear').textContent = s.year || 'Année inconnue';
document.getElementById('modalPath').textContent = '';
document.getElementById('modalSize').textContent = '';
const badges = [
`<span class="badge series">${s.seasons_count} saison${s.seasons_count!==1?'s':''}</span>`,
].join('');
document.getElementById('modalBadges').innerHTML = badges;
// Seasons grid
const grid = document.getElementById('modalSeasonsGrid');
grid.innerHTML = s.seasons.map(sn => `
<div class="season-chip">
<strong>Saison ${sn.season_number}</strong> — ${sn.episodes_count} épisode${sn.episodes_count!==1?'s':''}
<span style="color:#60a5fa;font-size:.7rem;margin-left:4px">${escHtml(sn.nas_name||'')}</span>
</div>`).join('');
document.getElementById('modalSeasons').style.display = 'block';
document.getElementById('modal').classList.add('open');
document.body.style.overflow = 'hidden';
}
function closeModal() {
document.getElementById('modal').classList.remove('open');
document.body.style.overflow = '';
}
// ─── Scan ────────────────────────────────
async function confirmFullScan() {
if (!confirm('Lancer un scan complet ?\nCette opération peut prendre plusieurs minutes selon la taille de vos NAS.\n\nNote : les jaquettes seront récupérées depuis TMDB pour chaque nouveau fichier.')) return;
startScan('full');
}
async function startScan(type) {
const btnFull = document.getElementById('btnFull');
const btnInc = document.getElementById('btnIncremental');
const fab = document.getElementById('syncFab');
try {
btnFull.disabled = btnInc.disabled = true;
fab.classList.add('scanning');
const data = await apiPost(`/api/scan/${type}`);
showScanProgress({ status: 'running', type });
startPolling(data.scan_id);
} catch(e) {
alert(`Erreur : ${e.message}`);
btnFull.disabled = btnInc.disabled = false;
fab.classList.remove('scanning');
}
}
function showScanProgress(data) {
const bar = document.getElementById('scanStatusBar');
bar.classList.add('visible');
const statusLabels = { running: 'En cours…', completed: 'Terminé', failed: 'Échoué' };
const typeLabels = { full: 'Scan complet', incremental: 'Scan incrémental' };
document.getElementById('scanStatusText').textContent = statusLabels[data.status] || data.status;
document.getElementById('scanStatusType').textContent = typeLabels[data.scan_type || data.type] || '';
document.getElementById('scanProgressFill').style.width = data.status === 'running' ? '60%' : '100%';
}
function startPolling(scanId) {
if (pollInterval) clearInterval(pollInterval);
pollInterval = setInterval(async () => {
try {
const data = await api(`/api/scan/status/${scanId}`);
showScanProgress(data);
if (data.status === 'completed' || data.status === 'failed') {
clearInterval(pollInterval); pollInterval = null;
document.getElementById('btnFull').disabled = document.getElementById('btnIncremental').disabled = false;
document.getElementById('syncFab').classList.remove('scanning');
loadStats(); loadNas(); loadScanHistory();
if (data.status === 'failed') alert(`Le scan a échoué : ${data.error_message || 'erreur inconnue'}`);
}
} catch(e) { console.error('Poll error:', e); }
}, 2000);
}
async function checkActiveScans() {
try {
const history = await api('/api/scan/history');
const running = history.find(s => s.status === 'running');
if (running) {
document.getElementById('btnFull').disabled = document.getElementById('btnIncremental').disabled = true;
document.getElementById('syncFab').classList.add('scanning');
showScanProgress(running);
startPolling(running.scan_id);
}
} catch(e) { console.error(e); }
}
async function loadScanHistory() {
try {
const history = await api('/api/scan/history');
const el = document.getElementById('scanHistory');
if (!history.length) { el.innerHTML = '<div class="empty-state">Aucun scan effectué</div>'; return; }
el.innerHTML = history.slice(0,5).map(h => `
<div class="scan-history-item">
<div class="sh-left">
<span class="sh-status ${h.status}">${h.status==='completed'?'OK':h.status==='running'?'…':'KO'}</span>
${h.scan_type==='full'?'Complet':'Incrémental'} — +${h.files_added} ajouté${h.files_added!==1?'s':''}
</div>
<div class="sh-right">${fmtDate(h.started_at)}</div>
</div>`).join('');
} catch(e) { console.error(e); }
}
</script>
</body>
</html>

108
generate-compose.py Normal file
View File

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

52
nginx/nginx.conf Normal file
View File

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