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:
30
.env.example
Normal file
30
.env.example
Normal 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
32
.gitignore
vendored
Normal 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
21
LICENSE
Normal 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
225
README.md
Normal 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
18
backend/Dockerfile
Normal 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
71
backend/config.py
Normal 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
194
backend/database.py
Normal 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
125
backend/main.py
Normal 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
178
backend/metadata.py
Normal 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
7
backend/requirements.txt
Normal 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
|
||||
0
backend/routers/__init__.py
Normal file
0
backend/routers/__init__.py
Normal file
98
backend/routers/scan.py
Normal file
98
backend/routers/scan.py
Normal 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
102
backend/routers/search.py
Normal 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
56
backend/routers/stats.py
Normal 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
478
backend/scanner.py
Normal 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
95
backend/scheduler.py
Normal 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
119
db/init.sql
Normal 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
50
docker-compose.yml
Normal 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
711
frontend/index.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ─── 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
108
generate-compose.py
Normal 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
52
nginx/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user