Implement MediaShelf v1
The application the design describes: Flask + SQLite, Plex for library data, Tautulli for watch history, report-only. Structure follows the design's seams. providers/ splits MediaProvider from HistoryProvider, because on this network library data and watch data live on different machines and Jellyfin later will have no Tautulli equivalent. scoring.py implements the reclaim score twice - as a SQL expression for the live grid (weights change on every slider drag, so storing it would mean rewriting thousands of rows per drag) and in Python for CSV export and tests, with a property test over 500 generated rows asserting the two agree. rules.py compiles saved views to parameterized SQL through a field/operator whitelist; nothing user-supplied is ever interpolated. Three properties are enforced by test rather than asserted in prose: - Ingest is idempotent. Three consecutive full scans leave every count and every byte total unchanged. A scanner that double-counts produces a report that looks plausible and is wrong. - Keep marks survive Plex reassigning every rating key in the library. They are keyed on content GUID, scoped per library so the Movies and 4K Movies copies of the same film mark independently. - Every config variable the app reads is declared in docker-compose.yml, so a variable set in Portainer can never silently do nothing. Also found and fixed while verifying against a fake Plex+Tautulli pair: executescript() commits the pending transaction, so migrations needed their BEGIN/COMMIT inside the script; replaceChildren() renders null as the literal text "null"; a hash-only URL change does not reload the document, so deep links needed a hashchange listener; and SQLite ROUND rounds half away from zero where Python rounds half to even. 73 tests, no live server required. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GVbG48GAXfCZatcmX123Ra
This commit is contained in:
parent
58c2883492
commit
6a557bcdd9
37 changed files with 6486 additions and 85 deletions
295
mediashelf/scoring.py
Normal file
295
mediashelf/scoring.py
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
"""The reclaim score (§6).
|
||||
|
||||
Implemented twice on purpose: once as a SQL expression, because weights change
|
||||
on every slider drag and storing the score would mean recomputing 5,700 rows per
|
||||
drag; and once in Python, for CSV export and for tests. Two implementations of
|
||||
one formula is a real risk, so test_scoring.py asserts they agree to within 0.01
|
||||
on a generated corpus.
|
||||
|
||||
Components are each normalized to [0, 1] where 1 means "better deletion
|
||||
candidate". The weighted mean uses only the components AVAILABLE for a row, so a
|
||||
missing component renormalizes instead of dragging every score down.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
DAY = 86400.0
|
||||
|
||||
COMPONENTS = ("size", "staleness", "unpopularity", "solitude", "age", "rejection")
|
||||
|
||||
DEFAULT_WEIGHTS = {
|
||||
"size": 0.28,
|
||||
"staleness": 0.24,
|
||||
"unpopularity": 0.22,
|
||||
"solitude": 0.10,
|
||||
"age": 0.10,
|
||||
"rejection": 0.06,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScoreContext:
|
||||
"""Per-request constants. max_size is computed once in a CTE, not per row."""
|
||||
|
||||
now: int
|
||||
max_size_bytes: int
|
||||
stale_horizon_days: int = 730
|
||||
age_horizon_days: int = 1095
|
||||
popular_at: int = 3
|
||||
rejected_at: int = 2
|
||||
solitude_at: int = 3
|
||||
grace_days: int = 30
|
||||
recent_days: int = 90
|
||||
has_completion_data: bool = True
|
||||
|
||||
|
||||
def normalize_weights(weights: dict | None, *, has_completion_data: bool = True) -> dict:
|
||||
w = dict(DEFAULT_WEIGHTS)
|
||||
if weights:
|
||||
for k, v in weights.items():
|
||||
if k in DEFAULT_WEIGHTS:
|
||||
try:
|
||||
w[k] = max(0.0, float(v))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if not has_completion_data:
|
||||
# No percent_complete anywhere => rejection is not merely zero, it is
|
||||
# unavailable, and must not dilute the remaining components (§4.11).
|
||||
w["rejection"] = 0.0
|
||||
if sum(w.values()) <= 0:
|
||||
w = dict(DEFAULT_WEIGHTS)
|
||||
return w
|
||||
|
||||
|
||||
# ─────────────────────────────── python ──────────────────────────────────
|
||||
|
||||
|
||||
def components(row: dict, ctx: ScoreContext) -> dict[str, float | None]:
|
||||
"""Component values for one row. None means 'not available for this row'."""
|
||||
size = int(row.get("size_bytes") or 0)
|
||||
added_at = row.get("added_at")
|
||||
last_watched = row.get("last_watched_at")
|
||||
watch_count = int(row.get("watch_count") or 0)
|
||||
abandoned = int(row.get("abandoned_count") or 0)
|
||||
watchers = int(row.get("distinct_watcher_count") or 0)
|
||||
episodes = int(row.get("episode_count") or 0)
|
||||
kind = row.get("kind") or "movie"
|
||||
pre_history = bool(row.get("pre_history"))
|
||||
|
||||
# size — log scale: 2GB vs 4GB matters more than 60GB vs 62GB
|
||||
max_size = max(int(ctx.max_size_bytes or 0), 1)
|
||||
c_size = math.log10(1 + size) / math.log10(1 + max_size) if size > 0 else 0.0
|
||||
c_size = min(max(c_size, 0.0), 1.0)
|
||||
|
||||
# staleness — never watched scores 1.0
|
||||
if last_watched:
|
||||
days = max(0.0, (ctx.now - last_watched) / DAY)
|
||||
c_stale = min(days / max(ctx.stale_horizon_days, 1), 1.0)
|
||||
else:
|
||||
c_stale = 1.0
|
||||
if pre_history and not last_watched:
|
||||
# Might have been watched before history coverage began; capping keeps a
|
||||
# 2009 film that was watched in 2015 from scoring as never-watched (§4.11)
|
||||
c_stale = min(c_stale, 0.75)
|
||||
|
||||
# unpopularity — TV normalized per-episode so it compares to a movie
|
||||
if kind == "season" and episodes > 0:
|
||||
normalized_watches = watch_count / episodes
|
||||
else:
|
||||
normalized_watches = float(watch_count)
|
||||
c_unpop = 1.0 - min(normalized_watches / max(ctx.popular_at, 1), 1.0)
|
||||
|
||||
# age
|
||||
if added_at:
|
||||
days = max(0.0, (ctx.now - added_at) / DAY)
|
||||
c_age = min(days / max(ctx.age_horizon_days, 1), 1.0)
|
||||
else:
|
||||
c_age = 0.0
|
||||
|
||||
# solitude
|
||||
c_sol = 1.0 - min(watchers / max(ctx.solitude_at, 1), 1.0)
|
||||
|
||||
# rejection — zeroed the moment anyone completes a view
|
||||
if not ctx.has_completion_data:
|
||||
c_rej = None
|
||||
elif watch_count > 0:
|
||||
c_rej = 0.0
|
||||
else:
|
||||
c_rej = min(abandoned / max(ctx.rejected_at, 1), 1.0)
|
||||
|
||||
return {
|
||||
"size": c_size,
|
||||
"staleness": c_stale,
|
||||
"unpopularity": c_unpop,
|
||||
"solitude": c_sol,
|
||||
"age": c_age,
|
||||
"rejection": c_rej,
|
||||
}
|
||||
|
||||
|
||||
def round_half_up(x: float, places: int = 2) -> float:
|
||||
"""Match SQLite's ROUND(), which rounds half away from zero.
|
||||
|
||||
Python's built-in round() uses banker's rounding, so the two disagree at
|
||||
exactly x.xx5 — the SQL grid would show 53.13 where the CSV export showed
|
||||
53.12. Harmless, but the kind of inconsistency that costs an afternoon when
|
||||
someone notices the two disagree and assumes the formula differs.
|
||||
"""
|
||||
factor = 10 ** places
|
||||
return math.floor(abs(x) * factor + 0.5) / factor * (1 if x >= 0 else -1)
|
||||
|
||||
|
||||
def score_row(row: dict, ctx: ScoreContext, weights: dict | None = None) -> dict:
|
||||
"""Returns {score, score_raw, components, grace}.
|
||||
|
||||
score_raw is unrounded — tests compare it against the SQL expression so a
|
||||
real formula divergence is not masked by rounding, and vice versa.
|
||||
"""
|
||||
w = normalize_weights(weights, has_completion_data=ctx.has_completion_data)
|
||||
comps = components(row, ctx)
|
||||
|
||||
num = 0.0
|
||||
den = 0.0
|
||||
for name in COMPONENTS:
|
||||
value = comps.get(name)
|
||||
weight = w.get(name, 0.0)
|
||||
if value is None or weight <= 0:
|
||||
continue
|
||||
num += weight * value
|
||||
den += weight
|
||||
score = 100.0 * (num / den) if den > 0 else 0.0
|
||||
|
||||
grace = None
|
||||
now = ctx.now
|
||||
added_at = row.get("added_at")
|
||||
last_watched = row.get("last_watched_at")
|
||||
if added_at and (now - added_at) < ctx.grace_days * DAY:
|
||||
score = 0.0
|
||||
grace = "new"
|
||||
elif last_watched and (now - last_watched) < ctx.recent_days * DAY:
|
||||
score = min(score, 25.0)
|
||||
grace = "recent"
|
||||
|
||||
return {
|
||||
"score": round_half_up(score, 2),
|
||||
"score_raw": score,
|
||||
"components": {k: (None if v is None else round(v, 4)) for k, v in comps.items()},
|
||||
"grace": grace,
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────── sql ────────────────────────────────────
|
||||
|
||||
|
||||
def sql_expression(ctx: ScoreContext, weights: dict | None = None,
|
||||
*, rounded: bool = True) -> tuple[str, dict]:
|
||||
"""Return (expression, params) computing the score for media_item rows.
|
||||
|
||||
Expects a CTE or join providing `maxsize` as the library-wide max size.
|
||||
Mirrors components()/score_row() exactly — see test_scoring.py. Pass
|
||||
rounded=False to compare the raw formula without rounding in the way.
|
||||
"""
|
||||
w = normalize_weights(weights, has_completion_data=ctx.has_completion_data)
|
||||
|
||||
p = {
|
||||
"now": ctx.now,
|
||||
"stale_h": max(ctx.stale_horizon_days, 1),
|
||||
"age_h": max(ctx.age_horizon_days, 1),
|
||||
"popular_at": max(ctx.popular_at, 1),
|
||||
"rejected_at": max(ctx.rejected_at, 1),
|
||||
"solitude_at": max(ctx.solitude_at, 1),
|
||||
"grace_s": ctx.grace_days * 86400,
|
||||
"recent_s": ctx.recent_days * 86400,
|
||||
"w_size": w["size"],
|
||||
"w_stale": w["staleness"],
|
||||
"w_unpop": w["unpopularity"],
|
||||
"w_sol": w["solitude"],
|
||||
"w_age": w["age"],
|
||||
"w_rej": w["rejection"],
|
||||
}
|
||||
|
||||
c_size = (
|
||||
"MIN(MAX(CASE WHEN i.size_bytes > 0 THEN "
|
||||
" (LOG(1 + i.size_bytes) / LOG(1 + MAX(s.maxsize, 1))) ELSE 0.0 END, 0.0), 1.0)"
|
||||
)
|
||||
|
||||
c_stale_raw = (
|
||||
"CASE WHEN i.last_watched_at IS NOT NULL AND i.last_watched_at > 0"
|
||||
" THEN MIN(MAX(CAST(:now - i.last_watched_at AS REAL) / 86400.0, 0.0)"
|
||||
" / :stale_h, 1.0)"
|
||||
" ELSE 1.0 END"
|
||||
)
|
||||
# pre_history cap applies only when there is no recorded watch at all
|
||||
c_stale = (
|
||||
"CASE WHEN i.pre_history = 1 AND (i.last_watched_at IS NULL OR i.last_watched_at = 0)"
|
||||
" THEN MIN(%s, 0.75) ELSE %s END" % (c_stale_raw, c_stale_raw)
|
||||
)
|
||||
|
||||
normalized_watches = (
|
||||
"CASE WHEN i.kind = 'season' AND i.episode_count > 0"
|
||||
" THEN CAST(i.watch_count AS REAL) / i.episode_count"
|
||||
" ELSE CAST(i.watch_count AS REAL) END"
|
||||
)
|
||||
c_unpop = "(1.0 - MIN((%s) / :popular_at, 1.0))" % normalized_watches
|
||||
|
||||
c_age = (
|
||||
"CASE WHEN i.added_at IS NOT NULL AND i.added_at > 0"
|
||||
" THEN MIN(MAX(CAST(:now - i.added_at AS REAL) / 86400.0, 0.0) / :age_h, 1.0)"
|
||||
" ELSE 0.0 END"
|
||||
)
|
||||
|
||||
c_sol = "(1.0 - MIN(CAST(i.distinct_watcher_count AS REAL) / :solitude_at, 1.0))"
|
||||
|
||||
if ctx.has_completion_data:
|
||||
c_rej = (
|
||||
"CASE WHEN i.watch_count > 0 THEN 0.0"
|
||||
" ELSE MIN(CAST(i.abandoned_count AS REAL) / :rejected_at, 1.0) END"
|
||||
)
|
||||
rej_num = ":w_rej * (%s)" % c_rej
|
||||
rej_den = ":w_rej"
|
||||
else:
|
||||
rej_num = "0.0"
|
||||
rej_den = "0.0"
|
||||
|
||||
numerator = (
|
||||
f":w_size * ({c_size}) + :w_stale * ({c_stale}) + :w_unpop * ({c_unpop})"
|
||||
f" + :w_sol * ({c_sol}) + :w_age * ({c_age}) + {rej_num}"
|
||||
)
|
||||
denominator = f"(:w_size + :w_stale + :w_unpop + :w_sol + :w_age + {rej_den})"
|
||||
|
||||
raw = f"CASE WHEN {denominator} > 0 THEN 100.0 * ({numerator}) / {denominator} ELSE 0.0 END"
|
||||
|
||||
graced = (
|
||||
"CASE"
|
||||
" WHEN i.added_at IS NOT NULL AND i.added_at > 0"
|
||||
" AND (:now - i.added_at) < :grace_s THEN 0.0"
|
||||
" WHEN i.last_watched_at IS NOT NULL AND i.last_watched_at > 0"
|
||||
" AND (:now - i.last_watched_at) < :recent_s THEN MIN(%s, 25.0)"
|
||||
" ELSE %s END" % (raw, raw)
|
||||
)
|
||||
return ("ROUND(%s, 2)" % graced if rounded else graced), p
|
||||
|
||||
|
||||
GRACE_SQL = (
|
||||
"CASE"
|
||||
" WHEN i.added_at IS NOT NULL AND i.added_at > 0"
|
||||
" AND (:now - i.added_at) < :grace_s THEN 'new'"
|
||||
" WHEN i.last_watched_at IS NOT NULL AND i.last_watched_at > 0"
|
||||
" AND (:now - i.last_watched_at) < :recent_s THEN 'recent'"
|
||||
" ELSE NULL END"
|
||||
)
|
||||
|
||||
|
||||
def register_sqlite_functions(conn) -> None:
|
||||
"""SQLite has no LOG() by default; add it (and a MIN/MAX-safe guard)."""
|
||||
def _log10(x):
|
||||
try:
|
||||
x = float(x)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return math.log10(x) if x > 0 else 0.0
|
||||
|
||||
conn.create_function("LOG", 1, _log10, deterministic=True)
|
||||
Loading…
Add table
Add a link
Reference in a new issue