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:
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()
|
||||
Reference in New Issue
Block a user