MediaShelf/mediashelf/scoring.py
Jess Hallsworth 6a557bcdd9
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
2026-09-07 14:59:03 +00:00

295 lines
10 KiB
Python

"""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)