history_coverage is only written at the end of an ingest, but has_completion_data was reading from it. During the first scan the coverage table is empty while watch_event already holds tens of thousands of Tautulli rows, so the dashboard announced a fallback that had not happened and claimed the rejection component was disabled when it was not. The flag now comes from the events themselves — does any row carry a percent_complete — which is true the moment Tautulli rows land and false for Plex-only history. history_source falls back to the scan record when coverage is absent, so it reads "tautulli" mid-scan instead of null. Also: the banner named Plex as the source without checking, and now reports whichever source is actually active, and stays quiet while a scan is running since the counts are still moving. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GVbG48GAXfCZatcmX123Ra
497 lines
18 KiB
Python
497 lines
18 KiB
Python
"""JSON API (§8). No auth in v1 — LAN-only, single admin view (§12)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import io
|
|
import json
|
|
import logging
|
|
import time
|
|
|
|
from flask import Blueprint, Response, current_app, jsonify, request
|
|
|
|
from . import keeps, queries, scanner
|
|
from .rules import RuleError
|
|
from .keeps import KeepError
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
bp = Blueprint("api", __name__, url_prefix="/api/v1")
|
|
|
|
|
|
def db():
|
|
return current_app.extensions["mediashelf"]["db"]
|
|
|
|
|
|
def cfg():
|
|
return current_app.extensions["mediashelf"]["config"]
|
|
|
|
|
|
@bp.errorhandler(RuleError)
|
|
def _rule_error(e):
|
|
return jsonify({"error": "invalid_rule", "message": str(e)}), 400
|
|
|
|
|
|
@bp.errorhandler(KeepError)
|
|
def _keep_error(e):
|
|
return jsonify({"error": "invalid_keep", "message": str(e)}), 400
|
|
|
|
|
|
def _json_arg(name):
|
|
raw = request.args.get(name)
|
|
if not raw:
|
|
return None
|
|
try:
|
|
return json.loads(raw)
|
|
except (TypeError, ValueError):
|
|
raise RuleError("%s must be valid JSON" % name)
|
|
|
|
|
|
def _bool_arg(name, default=False):
|
|
v = request.args.get(name)
|
|
if v is None:
|
|
return default
|
|
return v.lower() in ("1", "true", "yes")
|
|
|
|
|
|
def _query_kwargs():
|
|
rule_group = _json_arg("rules")
|
|
weights = _json_arg("weights")
|
|
|
|
view_id = request.args.get("view_id")
|
|
sort = request.args.get("sort")
|
|
if view_id:
|
|
row = db().one("SELECT * FROM saved_view WHERE id = ?", (view_id,))
|
|
if row is None:
|
|
raise RuleError("no such view")
|
|
rule_group = json.loads(row["rules"]) if row["rules"] else None
|
|
sort = sort or row["sort"]
|
|
if row["weights"] and not weights:
|
|
weights = json.loads(row["weights"])
|
|
|
|
return dict(
|
|
library_ids=request.args.getlist("library_id") or None,
|
|
kinds=request.args.getlist("kind") or None,
|
|
q=request.args.get("q"),
|
|
rule_group=rule_group,
|
|
include_missing=_bool_arg("include_missing"),
|
|
include_kept=_bool_arg("include_kept"),
|
|
include_shows=_bool_arg("include_shows"),
|
|
weights=weights,
|
|
), sort
|
|
|
|
|
|
# ── items ────────────────────────────────────────────────────────────────
|
|
|
|
@bp.get("/items")
|
|
def items():
|
|
kw, sort = _query_kwargs()
|
|
q = queries.Query(db(), cfg())
|
|
return jsonify(q.page(
|
|
sort=sort,
|
|
page=int(request.args.get("page", 1)),
|
|
page_size=int(request.args.get("page_size", 100)),
|
|
**kw,
|
|
))
|
|
|
|
|
|
@bp.get("/items/<int:item_id>")
|
|
def item_detail(item_id):
|
|
d = db()
|
|
q = queries.Query(d, cfg())
|
|
expr, where, params = q.build(include_kept=True, include_missing=True, include_shows=True)
|
|
params["item_id"] = item_id
|
|
sql = ("SELECT " + queries.BASE_COLUMNS + ", " + expr + " AS reclaim_score, "
|
|
+ "NULL AS grace, COALESCE(d.dupe_count,1) AS duplicate_count "
|
|
+ queries.FROM_CLAUSE + " WHERE i.id = :item_id")
|
|
row = d.one(sql, params)
|
|
if row is None:
|
|
return jsonify({"error": "not_found"}), 404
|
|
|
|
out = q.serialize(row)
|
|
out["parts"] = [dict(r) for r in d.query(
|
|
"SELECT * FROM media_part WHERE media_item_id = ? ORDER BY file_path", (item_id,))]
|
|
|
|
if out["kind"] == "season":
|
|
out["episodes"] = [dict(r) for r in d.query(
|
|
"SELECT * FROM episode WHERE season_item_id = ? ORDER BY episode_number",
|
|
(item_id,))]
|
|
pids = [e["provider_item_id"] for e in out["episodes"]]
|
|
else:
|
|
pids = [d.scalar("SELECT provider_item_id FROM media_item WHERE id=?", (item_id,))]
|
|
|
|
if pids:
|
|
marks = ",".join("?" * len(pids))
|
|
out["watch_history"] = [dict(r) for r in d.query(
|
|
"SELECT w.viewed_at, w.percent_complete, w.disposition, w.account_id, "
|
|
"COALESCE(a.friendly_name, a.name, w.account_id) AS who, w.platform "
|
|
"FROM watch_event w LEFT JOIN account a ON a.account_id = w.account_id "
|
|
f"WHERE w.provider_item_id IN ({marks}) ORDER BY w.viewed_at DESC LIMIT 500",
|
|
tuple(pids))]
|
|
else:
|
|
out["watch_history"] = []
|
|
|
|
if out.get("guid"):
|
|
out["duplicates"] = [dict(r) for r in d.query(
|
|
"SELECT i.id, i.title, i.size_bytes, i.resolution, i.watch_count, "
|
|
"lib.title AS library_title FROM media_item i "
|
|
"JOIN library lib ON lib.id=i.library_id "
|
|
"WHERE i.guid = ? AND i.id != ? AND i.kind='movie' AND i.status='present'",
|
|
(out["guid"], item_id))]
|
|
return jsonify(out)
|
|
|
|
|
|
@bp.get("/libraries")
|
|
def libraries():
|
|
return jsonify({"libraries": queries.size_by_library(db())})
|
|
|
|
|
|
@bp.get("/accounts")
|
|
def accounts():
|
|
return jsonify({"accounts": [dict(r) for r in db().query(
|
|
"SELECT a.*, (SELECT COUNT(*) FROM watch_event w WHERE w.account_id=a.account_id) "
|
|
"AS plays FROM account a ORDER BY plays DESC")]})
|
|
|
|
|
|
# ── stats ────────────────────────────────────────────────────────────────
|
|
|
|
@bp.get("/stats/overview")
|
|
def stats_overview():
|
|
return jsonify(queries.overview(db(), cfg()))
|
|
|
|
|
|
@bp.get("/stats/size-by-library")
|
|
def stats_size_by_library():
|
|
return jsonify({"libraries": queries.size_by_library(db())})
|
|
|
|
|
|
@bp.get("/stats/added-over-time")
|
|
def stats_added_over_time():
|
|
return jsonify({"buckets": queries.added_over_time(
|
|
db(), request.args.get("bucket", "month"))})
|
|
|
|
|
|
@bp.get("/stats/completion")
|
|
def stats_completion():
|
|
return jsonify(queries.completion_split(db()))
|
|
|
|
|
|
@bp.get("/stats/size-vs-lastwatched")
|
|
def stats_scatter():
|
|
return jsonify({"points": queries.size_vs_lastwatched(db())})
|
|
|
|
|
|
@bp.get("/stats/watch-distribution")
|
|
def stats_watch_distribution():
|
|
return jsonify({"buckets": [dict(r) for r in db().query("""
|
|
SELECT CASE WHEN watch_count = 0 THEN '0'
|
|
WHEN watch_count = 1 THEN '1'
|
|
WHEN watch_count <= 3 THEN '2-3'
|
|
WHEN watch_count <= 10 THEN '4-10'
|
|
ELSE '10+' END AS bucket,
|
|
COUNT(*) AS items, COALESCE(SUM(size_bytes),0) AS size_bytes
|
|
FROM media_item WHERE kind IN ('movie','season') AND status='present'
|
|
GROUP BY bucket""")]})
|
|
|
|
|
|
@bp.get("/duplicates")
|
|
def duplicates():
|
|
groups = queries.duplicate_groups(db())
|
|
return jsonify({
|
|
"groups": groups,
|
|
"total_redundant_bytes": sum(g["redundant_bytes"] for g in groups),
|
|
"group_count": len(groups),
|
|
})
|
|
|
|
|
|
@bp.get("/sources")
|
|
def sources():
|
|
d = db()
|
|
c = cfg()
|
|
cov = [dict(r) for r in d.query("SELECT * FROM history_coverage")]
|
|
return jsonify({
|
|
"plex": {"configured": c.plex_configured, "base_url": c.plex_base_url},
|
|
"tautulli": {"configured": c.tautulli_configured, "base_url": c.tautulli_base_url},
|
|
"history_source": queries.active_history_source(d),
|
|
"has_completion_data": queries.history_has_completion(d),
|
|
"coverage": cov,
|
|
"providers": [dict(r) for r in d.query("SELECT * FROM provider")],
|
|
})
|
|
|
|
|
|
# ── keep marks (§8.2) ────────────────────────────────────────────────────
|
|
|
|
@bp.get("/keeps")
|
|
def list_keeps():
|
|
d = db()
|
|
counts = keeps.mark_matches(d)
|
|
rows = []
|
|
for r in d.query(
|
|
"SELECT k.*, lib.title AS library_title FROM keep_mark k "
|
|
"JOIN library lib ON lib.id = k.library_id ORDER BY k.created_at DESC"
|
|
):
|
|
item = dict(r)
|
|
item["resolved_items"] = counts.get(r["id"], 0)
|
|
item["resolved_bytes"] = d.scalar(
|
|
"SELECT COALESCE(SUM(size_bytes),0) FROM media_item WHERE kept_mark_id = ?",
|
|
(r["id"],)) or 0
|
|
item["orphaned"] = item["resolved_items"] == 0
|
|
rows.append(item)
|
|
libs = [dict(r) for r in d.query(
|
|
"SELECT id, title, keep_all FROM library WHERE keep_all = 1 ORDER BY title")]
|
|
return jsonify({
|
|
"marks": rows,
|
|
"library_rules": libs,
|
|
"orphan_count": sum(1 for r in rows if r["orphaned"]),
|
|
"kept_bytes": d.scalar("SELECT COALESCE(SUM(size_bytes),0) FROM media_item "
|
|
"WHERE kept=1 AND kind IN ('movie','season')") or 0,
|
|
"kept_items": d.scalar("SELECT COUNT(*) FROM media_item "
|
|
"WHERE kept=1 AND kind IN ('movie','season')") or 0,
|
|
})
|
|
|
|
|
|
@bp.get("/keeps/orphans")
|
|
def keep_orphans():
|
|
d = db()
|
|
counts = keeps.mark_matches(d)
|
|
ids = [k for k, v in counts.items() if v == 0]
|
|
if not ids:
|
|
return jsonify({"marks": []})
|
|
marks = ",".join("?" * len(ids))
|
|
return jsonify({"marks": [dict(r) for r in d.query(
|
|
f"SELECT k.*, lib.title AS library_title FROM keep_mark k "
|
|
f"JOIN library lib ON lib.id=k.library_id WHERE k.id IN ({marks})", tuple(ids))]})
|
|
|
|
|
|
@bp.post("/keeps")
|
|
def create_keep():
|
|
payload = request.get_json(silent=True) or {}
|
|
d = db()
|
|
mode = payload.get("mode", "keep")
|
|
note = payload.get("note")
|
|
if payload.get("item_id"):
|
|
mark_id = keeps.create_from_item(d, int(payload["item_id"]), mode, note)
|
|
else:
|
|
required = ("scope", "library_id", "guid")
|
|
if not all(payload.get(k) for k in required):
|
|
raise KeepError("need item_id, or scope + library_id + guid")
|
|
mark_id = keeps.upsert(
|
|
d, payload["scope"], mode, int(payload["library_id"]), payload["guid"],
|
|
payload.get("season_number"), label=payload.get("label") or payload["guid"],
|
|
note=note)
|
|
keeps.resolve_all(d)
|
|
row = d.one("SELECT * FROM keep_mark WHERE id = ?", (mark_id,))
|
|
counts = keeps.mark_matches(d)
|
|
out = dict(row)
|
|
out["resolved_items"] = counts.get(mark_id, 0)
|
|
out["resolved_bytes"] = d.scalar(
|
|
"SELECT COALESCE(SUM(size_bytes),0) FROM media_item WHERE kept_mark_id=?",
|
|
(mark_id,)) or 0
|
|
return jsonify(out), 201
|
|
|
|
|
|
@bp.post("/keeps/bulk")
|
|
def bulk_keep():
|
|
payload = request.get_json(silent=True) or {}
|
|
ids = payload.get("item_ids") or []
|
|
if not isinstance(ids, list) or not ids:
|
|
raise KeepError("item_ids must be a non-empty list")
|
|
if len(ids) > 5000:
|
|
raise KeepError("too many items in one request")
|
|
mode = payload.get("mode", "keep")
|
|
note = payload.get("note")
|
|
d = db()
|
|
created, failed = [], []
|
|
for item_id in ids:
|
|
try:
|
|
created.append(keeps.create_from_item(d, int(item_id), mode, note))
|
|
except KeepError as e:
|
|
failed.append({"item_id": item_id, "reason": str(e)})
|
|
keeps.resolve_all(d)
|
|
return jsonify({
|
|
"created": len(created), "failed": failed,
|
|
"kept_bytes": d.scalar("SELECT COALESCE(SUM(size_bytes),0) FROM media_item "
|
|
"WHERE kept=1 AND kind IN ('movie','season')") or 0,
|
|
})
|
|
|
|
|
|
@bp.patch("/keeps/<int:mark_id>")
|
|
def patch_keep(mark_id):
|
|
payload = request.get_json(silent=True) or {}
|
|
db().execute("UPDATE keep_mark SET note=?, updated_at=? WHERE id=?",
|
|
(payload.get("note"), int(time.time()), mark_id))
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
@bp.delete("/keeps/<int:mark_id>")
|
|
def delete_keep(mark_id):
|
|
d = db()
|
|
ok = keeps.delete(d, mark_id)
|
|
keeps.resolve_all(d)
|
|
return jsonify({"deleted": ok}), (200 if ok else 404)
|
|
|
|
|
|
@bp.put("/libraries/<int:library_id>/keep_all")
|
|
def library_keep_all(library_id):
|
|
payload = request.get_json(silent=True) or {}
|
|
d = db()
|
|
keeps.set_library_keep_all(d, library_id, bool(payload.get("keep_all")))
|
|
keeps.resolve_all(d)
|
|
return jsonify({"ok": True, "library_id": library_id,
|
|
"keep_all": bool(payload.get("keep_all"))})
|
|
|
|
|
|
@bp.get("/keeps/export")
|
|
def export_keeps():
|
|
return Response(json.dumps(keeps.export(db()), indent=2),
|
|
mimetype="application/json",
|
|
headers={"Content-Disposition": "attachment; filename=keeps.json"})
|
|
|
|
|
|
@bp.post("/keeps/import")
|
|
def import_keeps():
|
|
payload = request.get_json(silent=True) or {}
|
|
return jsonify(keeps.import_(db(), payload))
|
|
|
|
|
|
# ── saved views (§8.3) ───────────────────────────────────────────────────
|
|
|
|
@bp.get("/views")
|
|
def list_views():
|
|
return jsonify({"views": [dict(r) for r in db().query(
|
|
"SELECT * FROM saved_view ORDER BY builtin DESC, name")]})
|
|
|
|
|
|
@bp.post("/views")
|
|
def create_view():
|
|
p = request.get_json(silent=True) or {}
|
|
if not p.get("name"):
|
|
return jsonify({"error": "name is required"}), 400
|
|
now = int(time.time())
|
|
cur = db().execute(
|
|
"INSERT INTO saved_view (name, description, rules, sort, columns, weights, "
|
|
"created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)",
|
|
(p["name"], p.get("description"), json.dumps(p.get("rules") or {}),
|
|
p.get("sort"), json.dumps(p.get("columns") or []),
|
|
json.dumps(p.get("weights") or {}), now, now))
|
|
return jsonify(dict(db().one("SELECT * FROM saved_view WHERE id=?", (cur.lastrowid,)))), 201
|
|
|
|
|
|
@bp.get("/views/<int:view_id>")
|
|
def get_view(view_id):
|
|
row = db().one("SELECT * FROM saved_view WHERE id=?", (view_id,))
|
|
return (jsonify(dict(row)), 200) if row else (jsonify({"error": "not_found"}), 404)
|
|
|
|
|
|
@bp.put("/views/<int:view_id>")
|
|
def update_view(view_id):
|
|
p = request.get_json(silent=True) or {}
|
|
db().execute(
|
|
"UPDATE saved_view SET name=COALESCE(?,name), description=?, rules=?, sort=?, "
|
|
"columns=?, weights=?, updated_at=? WHERE id=?",
|
|
(p.get("name"), p.get("description"), json.dumps(p.get("rules") or {}),
|
|
p.get("sort"), json.dumps(p.get("columns") or []),
|
|
json.dumps(p.get("weights") or {}), int(time.time()), view_id))
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
@bp.delete("/views/<int:view_id>")
|
|
def delete_view(view_id):
|
|
cur = db().execute("DELETE FROM saved_view WHERE id=? AND builtin=0", (view_id,))
|
|
return jsonify({"deleted": cur.rowcount > 0})
|
|
|
|
|
|
# ── scans (§8.4) ─────────────────────────────────────────────────────────
|
|
|
|
@bp.get("/scans")
|
|
def list_scans():
|
|
return jsonify({"scans": [dict(r) for r in db().query(
|
|
"SELECT * FROM scan ORDER BY id DESC LIMIT 50")]})
|
|
|
|
|
|
@bp.get("/scans/current")
|
|
def current_scan():
|
|
row = db().one("SELECT * FROM scan WHERE status='running' ORDER BY id DESC LIMIT 1")
|
|
return jsonify(dict(row) if row else None)
|
|
|
|
|
|
@bp.post("/scans")
|
|
def start_scan():
|
|
p = request.get_json(silent=True) or {}
|
|
mode = p.get("mode", "incremental")
|
|
if mode not in ("full", "incremental"):
|
|
return jsonify({"error": "mode must be 'full' or 'incremental'"}), 400
|
|
started = scanner.start_background_scan(current_app, mode, "manual")
|
|
if not started:
|
|
return jsonify({"error": "a scan is already running"}), 409
|
|
return jsonify({"status": "started", "mode": mode}), 202
|
|
|
|
|
|
# ── export (§8.5) ────────────────────────────────────────────────────────
|
|
|
|
CSV_COLUMNS = [
|
|
"id", "kind", "library", "title", "show_title", "season_number", "year",
|
|
"size_bytes", "size_human", "added_at_iso", "last_watched_at_iso",
|
|
"watch_count", "partial_count", "abandoned_count", "distinct_watcher_count",
|
|
"episode_count", "part_count", "resolution", "duplicate_count",
|
|
"pre_history", "kept", "kept_via", "reclaim_score", "primary_path",
|
|
]
|
|
|
|
|
|
def _human(n):
|
|
n = float(n or 0)
|
|
for u in ("B", "KB", "MB", "GB", "TB", "PB"):
|
|
if abs(n) < 1024:
|
|
return "%.1f %s" % (n, u)
|
|
n /= 1024.0
|
|
return "%.1f EB" % n
|
|
|
|
|
|
def _iso(ts):
|
|
if not ts:
|
|
return ""
|
|
return time.strftime("%Y-%m-%d", time.localtime(int(ts)))
|
|
|
|
|
|
@bp.get("/export.csv")
|
|
def export_csv():
|
|
kw, sort = _query_kwargs()
|
|
d = db()
|
|
q = queries.Query(d, cfg())
|
|
|
|
def generate():
|
|
buf = io.StringIO()
|
|
w = csv.writer(buf)
|
|
w.writerow(CSV_COLUMNS)
|
|
yield buf.getvalue()
|
|
buf.seek(0), buf.truncate(0)
|
|
for row in q.iter_all(sort=sort, **kw):
|
|
r = dict(row)
|
|
w.writerow([
|
|
r["id"], r["kind"], r["library_title"], r["title"],
|
|
r.get("show_title") or "", r.get("season_number") or "",
|
|
r.get("year") or "", r.get("size_bytes") or 0,
|
|
_human(r.get("size_bytes")), _iso(r.get("added_at")),
|
|
_iso(r.get("last_watched_at")), r.get("watch_count") or 0,
|
|
r.get("partial_count") or 0, r.get("abandoned_count") or 0,
|
|
r.get("distinct_watcher_count") or 0,
|
|
r.get("episode_count") if r["kind"] == "season" else "",
|
|
r.get("part_count") or 0, r.get("resolution") or "",
|
|
r.get("duplicate_count") or 1,
|
|
"yes" if r.get("pre_history") else "no",
|
|
"yes" if r.get("kept") else "no", r.get("kept_via") or "",
|
|
r.get("reclaim_score"), r.get("primary_path") or "",
|
|
])
|
|
yield buf.getvalue()
|
|
buf.seek(0), buf.truncate(0)
|
|
|
|
stamp = time.strftime("%Y%m%d-%H%M")
|
|
return Response(generate(), mimetype="text/csv", headers={
|
|
"Content-Disposition": "attachment; filename=mediashelf-%s.csv" % stamp})
|
|
|
|
|
|
# ── settings ─────────────────────────────────────────────────────────────
|
|
|
|
@bp.get("/settings")
|
|
def settings():
|
|
return jsonify(cfg().redacted())
|