Initial release — MediaTracker v1.0

Dockerized media library scanner for NAS drives.
Identifies movies and series from filenames, fetches posters
via the public IMDB suggestion API (no key required), and
exposes a dark-themed web UI with multilingual search support.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
snoopy
2026-05-19 11:40:44 +00:00
commit 6f551ba439
21 changed files with 2770 additions and 0 deletions

View File

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

@@ -0,0 +1,98 @@
from fastapi import APIRouter, HTTPException, Request
from database import ScanHistory, get_session
from scheduler import (
get_active_scan_id,
is_scan_running,
trigger_full_scan,
trigger_incremental_scan,
)
router = APIRouter(prefix="/api/scan", tags=["scan"])
@router.post("/full")
def start_full_scan(request: Request):
settings = request.app.state.settings
if is_scan_running():
raise HTTPException(status_code=409, detail="A scan is already running")
scan_id = trigger_full_scan(settings)
if scan_id is None:
raise HTTPException(status_code=409, detail="A scan is already running")
return {"scan_id": scan_id, "type": "full", "status": "running"}
@router.post("/incremental")
def start_incremental_scan(request: Request):
settings = request.app.state.settings
if is_scan_running():
raise HTTPException(status_code=409, detail="A scan is already running")
scan_id = trigger_incremental_scan(settings)
if scan_id is None:
raise HTTPException(status_code=409, detail="A scan is already running")
return {"scan_id": scan_id, "type": "incremental", "status": "running"}
@router.get("/status/{scan_id}")
def get_scan_status(scan_id: int):
session = get_session()
try:
record = session.query(ScanHistory).get(scan_id)
if record is None:
raise HTTPException(status_code=404, detail="Scan not found")
return {
"scan_id": record.id,
"type": record.scan_type,
"status": record.status,
"started_at": record.started_at.isoformat() if record.started_at else None,
"finished_at": record.finished_at.isoformat() if record.finished_at else None,
"files_found": record.files_found,
"files_added": record.files_added,
"files_removed": record.files_removed,
"files_unmatched": record.files_unmatched,
"error_message": record.error_message,
}
finally:
session.close()
@router.post("/fetch-alt-titles")
def fetch_alt_titles_now():
"""Trigger a Wikidata alt-title fetch in the background (no file scan)."""
def _run():
session = get_session()
try:
_fetch_alt_titles(session)
finally:
session.close()
threading.Thread(target=_run, daemon=True, name="alt-titles-fetch").start()
return {"status": "started"}
@router.get("/history")
def get_scan_history():
session = get_session()
try:
records = (
session.query(ScanHistory)
.order_by(ScanHistory.started_at.desc())
.limit(20)
.all()
)
return [
{
"scan_id": r.id,
"type": r.scan_type,
"status": r.status,
"started_at": r.started_at.isoformat() if r.started_at else None,
"finished_at": r.finished_at.isoformat() if r.finished_at else None,
"files_found": r.files_found,
"files_added": r.files_added,
"files_removed": r.files_removed,
"files_unmatched": r.files_unmatched,
"error_message": r.error_message,
}
for r in records
]
finally:
session.close()

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

@@ -0,0 +1,102 @@
from fastapi import APIRouter, Query
from database import Movie, NasSource, Season, Series, get_session
from metadata import _imdb_suggest
router = APIRouter(prefix="/api/search", tags=["search"])
_IMDB_QID = {"movie": "movie", "series": "tvSeries"}
def _imdb_ids_for_query(q: str, kind: str) -> list[str]:
"""Search IMDB with the user query and return matching IMDB IDs."""
items = _imdb_suggest(q)
qid = _IMDB_QID.get(kind)
return [it["id"] for it in items if it.get("qid") == qid and it.get("id")]
def _movie_matches(session, pattern: str, q: str) -> list:
"""Return movies matching by title in DB, OR via IMDB cross-reference."""
# Direct title match in DB
by_title = session.query(Movie).filter(Movie.title.ilike(pattern)).all()
found_ids = {m.id for m in by_title}
# Cross-language: search IMDB and look up matching IMDB IDs in our DB
imdb_ids = _imdb_ids_for_query(q, "movie")
if imdb_ids:
cross = session.query(Movie).filter(
Movie.imdb_id.in_(imdb_ids),
Movie.id.notin_(found_ids) if found_ids else Movie.id.isnot(None),
).all()
by_title = by_title + cross
return sorted(by_title, key=lambda m: m.title)[:50]
def _series_matches(session, pattern: str, q: str) -> list:
"""Return series matching by title in DB, OR via IMDB cross-reference."""
by_title = session.query(Series).filter(Series.title.ilike(pattern)).all()
found_ids = {s.id for s in by_title}
imdb_ids = _imdb_ids_for_query(q, "series")
if imdb_ids:
cross = session.query(Series).filter(
Series.imdb_id.in_(imdb_ids),
Series.id.notin_(found_ids) if found_ids else Series.id.isnot(None),
).all()
by_title = by_title + cross
return sorted(by_title, key=lambda s: s.title)[:50]
@router.get("")
def search(
q: str = Query(..., min_length=1),
type: str = Query("all", pattern="^(all|movie|series)$"),
):
session = get_session()
try:
pattern = f"%{q}%"
results = {"movies": [], "series": []}
if type in ("all", "movie"):
for m in _movie_matches(session, pattern, q):
nas = session.query(NasSource).get(m.nas_id)
results["movies"].append({
"id": m.id,
"title": m.title,
"year": m.year,
"quality": m.quality,
"codec": m.codec,
"nas_name": nas.name if nas else None,
"file_path": m.file_path,
"file_size": m.file_size,
"poster_url": m.poster_url,
"imdb_id": m.imdb_id,
})
if type in ("all", "series"):
for s in _series_matches(session, pattern, q):
seasons_data = []
for season in sorted(s.seasons, key=lambda x: (x.season_number, x.nas_id)):
nas = session.query(NasSource).get(season.nas_id)
seasons_data.append({
"season_number": season.season_number,
"episodes_count": len(season.episodes),
"nas_name": nas.name if nas else None,
"nas_id": season.nas_id,
})
results["series"].append({
"id": s.id,
"title": s.title,
"year": s.year,
"seasons_count": len(s.seasons),
"seasons": seasons_data,
"poster_url": s.poster_url,
"imdb_id": s.imdb_id,
})
total = len(results["movies"]) + len(results["series"])
return {"query": q, "results": results, "total": total}
finally:
session.close()

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

@@ -0,0 +1,56 @@
import os
from fastapi import APIRouter
from database import Episode, Movie, NasSource, ScanHistory, Season, Series, UnmatchedFile, get_session
router = APIRouter(prefix="/api", tags=["stats"])
@router.get("/stats")
def get_stats():
session = get_session()
try:
movies_count = session.query(Movie).count()
series_count = session.query(Series).count()
episodes_count = session.query(Episode).count()
nas_count = session.query(NasSource).count()
unmatched_count = session.query(UnmatchedFile).count()
last_scan = (
session.query(ScanHistory)
.filter_by(status="completed")
.order_by(ScanHistory.finished_at.desc())
.first()
)
return {
"movies": movies_count,
"series": series_count,
"episodes": episodes_count,
"nas_count": nas_count,
"unmatched_files": unmatched_count,
"last_scan": last_scan.finished_at.isoformat() if last_scan and last_scan.finished_at else None,
}
finally:
session.close()
@router.get("/nas")
def get_nas_list():
session = get_session()
try:
sources = session.query(NasSource).all()
result = []
for nas in sources:
accessible = os.path.isdir(nas.path)
result.append({
"id": nas.id,
"name": nas.name,
"path": nas.path,
"accessible": accessible,
"last_scan": nas.last_scan.isoformat() if nas.last_scan else None,
})
return result
finally:
session.close()