OverviewFilesCommitsIssuesDocsReleasesPeers

stremio-vsembed commits / b1b65f3

Retention: refuse before the volume fills, delete only what is derived (0.17.1)
The library is not a cache. test_library_ingest opens by saying so, and the volume agrees: 745 GB, the overwhelming majority of it never fetched by this addon. An episode it did fetch is exactly as permanent as one that arrived another way, so nothing here is allowed to delete an episode. What protects the disk is refusal. Below a 25 GB floor an ingest declines to start, checked before the resolve so a full volume costs no browser. That is the honest order of preference -- a copy we have beats a copy we were about to make, so the new one yields -- and it fails visibly, as a line on the audit page, rather than quietly eating the last of the volume. What may be reclaimed is only what can be PROVED derived: a partial remux nothing is working on, and a legacy mp4-cache copy the library has superseded AND whose library copy passes the completeness gate. Both halves of that second one matter; without the gate, a truncated library copy would be grounds for deleting the good legacy one. It is driven from the ingest queue, not by walking the volume -- bounded, and honest about scope. On the real data that is the whole point: mp4-cache holds X-Men '97 S02E07, which was never ingested to the library, so it is the ONLY copy of that episode and nothing here is entitled to touch it. Likewise a 2019 Box Left.png.part in a scans folder that has nothing to do with us. Today exactly one thing is reclaimable, a 118 MB abandoned remux. _reclaim() takes no arguments. The set is re-derived rather than taken from the request, so no caller can ever name a path for deletion. Also, the queue itself. _load_job_queue answers {} for anything it cannot parse, by design, so a bad file cannot stop the addon booting. Right for a reader, catastrophic for a writer: it rebuilds the queue from nothing and persists it, so ONE unreadable read destroys every job on record. That is the same shape as ffmpeg exiting 0 on a truncated download -- an error path reporting success. _set_job_state now uses a raising read and skips the write instead, costing one unrecorded state change rather than the lot. The real queue had already gone from twenty entries to one. Rebuilt from the service journal, which records every ingest start, done and failure; nothing invented, and the previous file kept as .bak-2026-08-31. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
b1b65f39ff031c50fa1d2fad75435f70b0548bbf
by plankatron <fleeky@gmail.com> · 2026-08-31T15:06:30.000Z

Changed files (3) +421 -6

addon.py +197 -6
……@@ -10,6 +10,7 @@ import logging
1010import os
1111import random
1212import re
13import shutil
1314import subprocess
1415import threading
1516import time
……@@ -596,7 +597,7 @@ TV_GENRES = [
596597
597598MANIFEST = {
598599 "id": "org.tabertron.vsembed",
599 "version": "0.16.1",
600 "version": "0.17.1",
600601 "name": "Tabertron VsEmbed",
601602 "description": "Self-hosted HLS extractor with TMDB browse + search, played via vidcore",
602603 "types": ["movie", "series"],
……@@ -1208,14 +1209,30 @@ INGEST_QUEUE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)),
12081209_queue_guard = threading.Lock()
12091210
12101211
1212def _read_job_queue() -> dict:
1213 """The queue as stored. RAISES if the file exists but cannot be read.
1214
1215 The raising version exists so a writer can tell "there is no queue yet" from
1216 "there is a queue and I could not read it". Those must not be treated alike:
1217 the first is an empty dict, the second is every job on record.
1218 """
1219 try:
1220 with open(INGEST_QUEUE_FILE) as f:
1221 data = json.load(f)
1222 except FileNotFoundError:
1223 return {}
1224 if not isinstance(data, dict):
1225 raise ValueError("ingest queue is not a JSON object")
1226 return data
1227
1228
12111229def _load_job_queue() -> dict:
12121230 """Persisted job states. Never raises — this is a convenience, and a bad
12131231 file must not be a reason to refuse to boot."""
12141232 try:
1215 with open(INGEST_QUEUE_FILE) as f:
1216 data = json.load(f)
1217 return data if isinstance(data, dict) else {}
1218 except Exception:
1233 return _read_job_queue()
1234 except Exception as exc:
1235 log.warning("ingest queue unreadable (%s) — reading as empty", exc)
12191236 return {}
12201237
12211238
……@@ -1227,7 +1244,12 @@ def _set_job_state(key: str, state: str, args: Optional[tuple] = None) -> None:
12271244 _mp4_jobs[key] = state
12281245 try:
12291246 with _queue_guard:
1230 queue = _load_job_queue()
1247 # The RAISING read, deliberately. _load_job_queue answers {} for a
1248 # file it cannot parse, which is right for a reader and catastrophic
1249 # for a writer: it would rebuild the queue from nothing and persist
1250 # that, so one unreadable read destroys every job on record. Letting
1251 # it raise here costs a single unrecorded state change instead.
1252 queue = _read_job_queue()
12311253 entry = queue.get(key) or {}
12321254 entry["state"] = state
12331255 if args is not None:
……@@ -1711,6 +1733,10 @@ def _run_ingest(kind: str, ident: str, season: int, episode: int,
17111733 # speculative indirection is how the special-casing this replaces began.
17121734 if not ingest_source():
17131735 return "failed: no ingestable source"
1736 # Checked before the resolve, so a full volume costs no browser at all.
1737 if not _enough_space():
1738 log.warning("ingest: declining %s — under the free-space floor", key)
1739 return "failed: below the free-space floor"
17141740 meta = _library_meta(kind, ident, season, episode)
17151741 if not meta:
17161742 return f"failed: cannot identify {ident}"
……@@ -2482,6 +2508,120 @@ def meta(ctype: str, meta_id: str):
24822508 return jsonify({"meta": meta_obj})
24832509
24842510
2511# -------------------------------- retention ---------------------------------
2512# The library is NOT a cache. test_library_ingest says so in its first line, and
2513# the volume agrees: 745 GB of it, the overwhelming majority never fetched by
2514# this addon at all. So retention has no authority over Shows/ and Movies/. It
2515# may reclaim only what it can PROVE is derived, and it may never delete an
2516# episode to make room for another one.
2517#
2518# What protects the disk is therefore refusal, not deletion: below the floor an
2519# ingest declines to start. That is the honest order of preference -- a copy we
2520# have is worth more than a copy we were about to make, so the new one yields.
2521# It also fails visibly, as a row on the audit page, instead of quietly eating
2522# the last of the volume.
2523#
2524# The floor is deliberately generous. At roughly 1.3 GB an episode it leaves
2525# about twenty episodes of headroom, which is enough to notice and act before
2526# anything is actually at risk.
2527LIBRARY_MIN_FREE_BYTES = 25 * 1024 ** 3
2528
2529
2530def _disk() -> Optional[tuple[int, int, int]]:
2531 """(total, used, free) for the library volume, or None if unreadable."""
2532 try:
2533 usage = shutil.disk_usage(LIBRARY_ROOT)
2534 return usage.total, usage.used, usage.free
2535 except OSError as exc:
2536 log.warning("retention: cannot stat %s (%s)", LIBRARY_ROOT, exc)
2537 return None
2538
2539
2540def _enough_space() -> bool:
2541 """False when an ingest should decline to start.
2542
2543 Unreadable disk means True: this is a brake, and a brake that cannot read
2544 the road must not stop the car. The same rule the completeness gate follows
2545 for a missing runtime.
2546 """
2547 disk = _disk()
2548 if disk is None:
2549 return True
2550 return disk[2] >= LIBRARY_MIN_FREE_BYTES
2551
2552
2553def _reclaimable() -> list[dict]:
2554 """Artifacts that are PROVABLY derived, and therefore safe to remove.
2555
2556 Driven from the ingest queue rather than by walking the volume. That keeps
2557 it bounded -- twenty stats instead of a crawl over 745 GB -- and it keeps
2558 the scope honest: these are the copies this addon made, and the only ones it
2559 can reason about. A file it cannot tie back to a job it ran is left alone,
2560 which is why the single legacy copy in mp4-cache survives: X-Men '97 S02E07
2561 was never ingested to the library, so it is the ONLY copy of that episode
2562 and nothing here is entitled to touch it.
2563 """
2564 out: list[dict] = []
2565 now = time.time()
2566 for key, entry in _load_job_queue().items():
2567 args = entry.get("args") or []
2568 if len(args) < 5:
2569 continue
2570 kind, ident, season, episode, height = args[:5]
2571 season, episode, height = int(season), int(episode), int(height)
2572 meta = _library_meta(kind, ident, season, episode)
2573 if not meta:
2574 continue
2575 dest = _library_path(kind, meta["title"], meta["year"], meta["tmdb_id"],
2576 season, episode, meta["episode_title"])
2577 part = _part_path(dest)
2578 try:
2579 st = os.stat(part)
2580 # Only once it cannot still be in flight. A remux may legitimately
2581 # run for MP4_REMUX_TIMEOUT, and the running job would rather find
2582 # its own file than a surprise.
2583 if (_mp4_jobs.get(key) != "running"
2584 and now - st.st_mtime > MP4_REMUX_TIMEOUT):
2585 out.append({"path": part, "bytes": st.st_size,
2586 "reason": "abandoned partial remux"})
2587 except OSError:
2588 pass
2589 # A legacy mp4-cache copy is redundant ONLY once the library holds the
2590 # same episode and that copy passes the completeness gate. Both halves
2591 # matter: without the second, a truncated library copy would be grounds
2592 # for deleting the good legacy one.
2593 legacy = _mp4_path(kind, ident, season, episode, height)
2594 if legacy != dest and os.path.exists(legacy) and _library_has(
2595 kind, ident, season, episode):
2596 try:
2597 out.append({"path": legacy, "bytes": os.path.getsize(legacy),
2598 "reason": "superseded by the library copy"})
2599 except OSError:
2600 pass
2601 return out
2602
2603
2604def _reclaim() -> dict:
2605 """Remove everything _reclaimable() currently vouches for.
2606
2607 The set is RE-DERIVED here rather than taken from the request. A caller does
2608 not get to name a path for deletion: the only paths that can ever be removed
2609 are ones this function independently proved were derived, a moment ago.
2610 """
2611 freed, removed = 0, []
2612 for item in _reclaimable():
2613 try:
2614 os.remove(item["path"])
2615 except OSError as exc:
2616 log.warning("reclaim: %s (%s)", item["path"], exc)
2617 continue
2618 freed += item["bytes"]
2619 removed.append(item["path"])
2620 log.info("reclaimed %s (%s, %.0f MB)", os.path.basename(item["path"]),
2621 item["reason"], item["bytes"] / 1e6)
2622 return {"removed": removed, "freed": freed}
2623
2624
24852625# ------------------------------ library audit -------------------------------
24862626# The truncation this exists for was invisible for five days and was found by
24872627# watching an episode, not by any check. Every number needed to spot it was
……@@ -2562,9 +2702,45 @@ tr.missing,tr.unknown{background:#fff8e1}
25622702.tag.ok{background:#2e7d32;color:#fff}
25632703button{padding:.3rem .7rem;cursor:pointer}
25642704td.num{text-align:right;font-variant-numeric:tabular-nums}
2705p.disk{color:#555}
2706p.low{color:#b71c1c;font-weight:600}
25652707"""
25662708
25672709
2710def _gb(n):
2711 return f"{n / 1024 ** 3:.0f} GB"
2712
2713
2714def _disk_html() -> str:
2715 """The budget, and whatever is provably safe to remove.
2716
2717 Shown even when there is nothing to reclaim: the number that matters is the
2718 headroom, and the point of putting it here is that the season button can
2719 spend it twelve gigabytes at a time.
2720 """
2721 disk = _disk()
2722 out = []
2723 if disk:
2724 total, _used, free = disk
2725 low = free < LIBRARY_MIN_FREE_BYTES
2726 out.append(
2727 f"<p class='{'low' if low else 'disk'}'>"
2728 f"{_gb(free)} free of {_gb(total)}"
2729 f" &middot; floor {_gb(LIBRARY_MIN_FREE_BYTES)}"
2730 + (" &middot; <b>ingests are declining until this clears</b>"
2731 if low else "") + "</p>")
2732 items = _reclaimable()
2733 if not items:
2734 return "".join(out) + "<p class=disk>Nothing to reclaim.</p>"
2735 freed = sum(i["bytes"] for i in items)
2736 out.append(f"<form method=post action=/library/reclaim><p class=disk>"
2737 f"{len(items)} reclaimable ({freed / 1e6:.0f} MB): "
2738 + "; ".join(f"{html.escape(os.path.basename(i['path']))} "
2739 f"&mdash; {html.escape(i['reason'])}" for i in items)
2740 + " <button>Reclaim</button></p></form>")
2741 return "".join(out)
2742
2743
25682744def _audit_html(rows: list[dict]) -> str:
25692745 def mins(seconds):
25702746 return "—" if not seconds else f"{seconds / 60:.0f}m"
……@@ -2573,6 +2749,7 @@ def _audit_html(rows: list[dict]) -> str:
25732749 short = sum(1 for r in rows if r["status"] == AUDIT_SHORT)
25742750 body.append(f"<h1>Library ({len(rows)} fetched"
25752751 + (f", <b>{short} incomplete</b>" if short else "") + ")</h1>")
2752 body.append(_disk_html())
25762753 body.append("<table><tr><th>Title</th><th></th><th>On disk</th>"
25772754 "<th>Expected</th><th></th><th>Checked</th><th></th></tr>")
25782755 for r in rows:
……@@ -2640,6 +2817,20 @@ def library_reingest():
26402817 return redirect("/library", code=303)
26412818
26422819
2820@app.route("/library/reclaim", methods=["POST"])
2821def library_reclaim():
2822 """POST only, like every other action here.
2823
2824 Takes no arguments on purpose. There is no path parameter to get wrong,
2825 because the caller does not choose what goes -- _reclaim re-derives the set
2826 itself and will only ever remove what it can prove is derived.
2827 """
2828 result = _reclaim()
2829 log.info("library: reclaimed %d file(s), %.0f MB",
2830 len(result["removed"]), result["freed"] / 1e6)
2831 return redirect("/library", code=303)
2832
2833
26432834@app.route("/library/season", methods=["POST"])
26442835def library_season():
26452836 """POST only, for the same reason as the re-ingest route."""
test_queue_atomicity.py +34 -0
……@@ -84,3 +84,37 @@ def test_no_temp_file_is_left_behind():
8484 leftovers = [f for f in os.listdir(os.path.dirname(addon.INGEST_QUEUE_FILE))
8585 if f.endswith(".tmp")]
8686 assert leftovers == []
87
88
89# --- a read failure must not become a write of nothing ----------------------
90#
91# The torn read above was only half of it. _load_job_queue answers {} for
92# anything it cannot parse, by design, so that a bad file cannot stop the addon
93# booting. That is right for a reader and catastrophic for a writer: it would
94# rebuild the queue from nothing and persist it. One unreadable read, and every
95# job on record is gone.
96#
97# This is not hypothetical. On 2026-08-31 the real queue went from twenty
98# entries to a single one left behind by a test.
99
100def test_an_unreadable_queue_is_not_overwritten_with_nothing():
101 with open(addon.INGEST_QUEUE_FILE, "w") as f:
102 f.write("{ this is not json")
103 addon._set_job_state("k", "done", args=("series", "tmdb:1", 1, 1, 720))
104 with open(addon.INGEST_QUEUE_FILE) as f:
105 assert f.read() == "{ this is not json"
106
107
108def test_a_queue_that_is_not_an_object_is_not_overwritten():
109 with open(addon.INGEST_QUEUE_FILE, "w") as f:
110 f.write('["a list, somehow"]')
111 addon._set_job_state("k", "done", args=("series", "tmdb:1", 1, 1, 720))
112 with open(addon.INGEST_QUEUE_FILE) as f:
113 assert json.load(f) == ["a list, somehow"]
114
115
116def test_a_missing_queue_is_still_created():
117 """"No file yet" and "file I cannot read" must not be treated alike."""
118 assert not os.path.exists(addon.INGEST_QUEUE_FILE)
119 addon._set_job_state("k", "done", args=("series", "tmdb:1", 1, 1, 720))
120 assert addon._load_job_queue()["k"]["state"] == "done"
test_retention.py +190 -0
……@@ -0,0 +1,190 @@
1"""Retention: refuse before the volume is full, and delete only what is derived.
2
3The library is not a cache. 745 GB of it sits on the volume, the overwhelming
4majority never fetched by this addon, and an episode it did fetch is exactly as
5permanent as one that arrived another way. So nothing here is allowed to delete
6an episode -- what protects the disk is an ingest DECLINING to start, which is
7the honest order of preference: a copy we have beats a copy we were about to
8make.
9
10What may be reclaimed is only what can be proved derived: a partial remux
11nothing is working on, and a legacy cache copy the library has superseded.
12
13Run: .venv/bin/python3 -m pytest test_retention.py -q
14"""
15
16import json
17import os
18
19import pytest
20
21import addon
22
23
24@pytest.fixture(autouse=True)
25def isolated(tmp_path, monkeypatch):
26 monkeypatch.setattr(addon, "LIBRARY_ROOT", str(tmp_path / "lib"))
27 monkeypatch.setattr(addon, "MP4_DIR", str(tmp_path / "cache"))
28 monkeypatch.setattr(addon, "INGEST_QUEUE_FILE",
29 os.path.join(str(tmp_path), "queue.json"))
30 monkeypatch.setattr(addon, "LIBRARY_VERIFIED_FILE",
31 os.path.join(str(tmp_path), "verified.json"))
32 monkeypatch.setattr(addon, "_library_meta_cache", {})
33 monkeypatch.setattr(addon, "_duration_cache", {})
34 monkeypatch.setattr(addon, "_mp4_jobs", {})
35 os.makedirs(str(tmp_path / "cache"), exist_ok=True)
36 monkeypatch.setattr(addon, "_tmdb_get", lambda path, params=None: {
37 "tv/1": {"name": "Show", "first_air_date": "2020-01-01"},
38 "tv/1/season/1/episode/1": {"name": "Pilot", "runtime": 50},
39 }[path])
40 yield
41
42
43def _queue():
44 with open(addon.INGEST_QUEUE_FILE, "w") as f:
45 json.dump({"series:tmdb:1:1:1:720": {
46 "state": "done", "args": ["series", "tmdb:1", 1, 1, 720],
47 "ts": 100.0}}, f)
48
49
50def _dest():
51 meta = addon._library_meta("series", "tmdb:1", 1, 1)
52 return addon._library_path("series", meta["title"], meta["year"],
53 meta["tmdb_id"], 1, 1, meta["episode_title"])
54
55
56def _write(path, size=1024, age=0.0):
57 os.makedirs(os.path.dirname(path), exist_ok=True)
58 with open(path, "wb") as f:
59 f.write(b"x" * size)
60 if age:
61 old = os.stat(path).st_mtime - age
62 os.utime(path, (old, old))
63
64
65# --- the floor --------------------------------------------------------------
66
67def test_a_full_volume_stops_an_ingest_rather_than_deleting_anything(monkeypatch):
68 """The whole policy in one assertion: the new copy yields, the old ones
69 stay."""
70 monkeypatch.setattr(addon, "_disk", lambda: (1000, 999, 1))
71 monkeypatch.setattr(addon, "ingest_source", lambda: object())
72 resolved = []
73 monkeypatch.setattr(addon, "_resolve_vidcore",
74 lambda *a, **k: resolved.append(1))
75 state = addon._run_ingest("series", "tmdb:1", 1, 1, 720)
76 assert state == "failed: below the free-space floor"
77 assert resolved == [] # and it cost no browser resolve
78
79
80def test_headroom_lets_an_ingest_proceed(monkeypatch):
81 monkeypatch.setattr(addon, "_disk",
82 lambda: (0, 0, addon.LIBRARY_MIN_FREE_BYTES * 2))
83 assert addon._enough_space() is True
84
85
86def test_an_unreadable_disk_does_not_stop_anything(monkeypatch):
87 """A brake that cannot read the road must not stop the car -- the same rule
88 the completeness gate follows for a missing runtime."""
89 monkeypatch.setattr(addon, "_disk", lambda: None)
90 assert addon._enough_space() is True
91
92
93# --- what may be reclaimed --------------------------------------------------
94
95def test_an_abandoned_partial_is_reclaimable():
96 _queue()
97 _write(addon._part_path(_dest()), age=addon.MP4_REMUX_TIMEOUT + 60)
98 assert [i["reason"] for i in addon._reclaimable()] == ["abandoned partial remux"]
99
100
101def test_a_partial_that_could_still_be_running_is_left_alone():
102 """A remux may legitimately run for MP4_REMUX_TIMEOUT."""
103 _queue()
104 _write(addon._part_path(_dest()), age=60)
105 assert addon._reclaimable() == []
106
107
108def test_a_partial_belonging_to_a_running_job_is_left_alone(monkeypatch):
109 _queue()
110 _write(addon._part_path(_dest()), age=addon.MP4_REMUX_TIMEOUT + 60)
111 monkeypatch.setattr(addon, "_mp4_jobs", {"series:tmdb:1:1:1:720": "running"})
112 assert addon._reclaimable() == []
113
114
115def test_a_legacy_copy_is_reclaimable_once_the_library_supersedes_it(monkeypatch):
116 _queue()
117 _write(addon._mp4_path("series", "tmdb:1", 1, 1, 720))
118 _write(_dest())
119 monkeypatch.setattr(addon, "_media_duration", lambda p: 3000.0)
120 assert [i["reason"] for i in addon._reclaimable()] == \
121 ["superseded by the library copy"]
122
123
124def test_a_legacy_copy_is_kept_when_the_library_copy_is_short(monkeypatch):
125 """Both halves of that check matter. Without the completeness gate, a
126 truncated library copy would be grounds for deleting the good legacy one."""
127 _queue()
128 _write(addon._mp4_path("series", "tmdb:1", 1, 1, 720))
129 _write(_dest())
130 monkeypatch.setattr(addon, "_media_duration", lambda p: 600.0)
131 assert addon._reclaimable() == []
132
133
134def test_a_legacy_copy_with_no_library_copy_is_never_touched():
135 """The real one: mp4-cache holds X-Men '97 S02E07, never ingested to the
136 library, so it is the ONLY copy of that episode."""
137 _queue()
138 _write(addon._mp4_path("series", "tmdb:1", 1, 1, 720))
139 assert addon._reclaimable() == []
140
141
142def test_a_file_we_cannot_tie_to_a_job_is_never_touched():
143 """Nothing is in the queue, so nothing is ours to reason about -- including
144 the unrelated 2019 Box Left.png.part sitting in a scans folder."""
145 _write(os.path.join(addon.LIBRARY_ROOT, "Shows", "Scans", "Box Left.png.part"),
146 age=addon.MP4_REMUX_TIMEOUT + 60)
147 _write(addon._part_path(_dest()), age=addon.MP4_REMUX_TIMEOUT + 60)
148 assert addon._reclaimable() == []
149
150
151def test_a_finished_library_episode_is_never_reclaimable(monkeypatch):
152 """The line this must not cross."""
153 _queue()
154 _write(_dest())
155 monkeypatch.setattr(addon, "_media_duration", lambda p: 3000.0)
156 assert addon._reclaimable() == []
157
158
159# --- reclaiming -------------------------------------------------------------
160
161def test_reclaim_removes_only_the_derived_file():
162 _queue()
163 part = addon._part_path(_dest())
164 _write(part, size=2048, age=addon.MP4_REMUX_TIMEOUT + 60)
165 _write(_dest())
166 result = addon._reclaim()
167 assert result["removed"] == [part] and result["freed"] == 2048
168 assert not os.path.exists(part) and os.path.exists(_dest())
169
170
171def test_reclaim_does_not_take_a_path_from_the_caller():
172 """_reclaim() takes no arguments: the set is re-derived, so no request can
173 ever name something for deletion."""
174 import inspect
175 assert list(inspect.signature(addon._reclaim).parameters) == []
176
177
178def test_the_route_is_not_reachable_by_a_get():
179 assert addon.app.test_client().get("/library/reclaim").status_code == 405
180
181
182def test_the_page_shows_the_budget(monkeypatch):
183 monkeypatch.setattr(addon, "_disk", lambda: (2 * 1024 ** 4, 0, 1024 ** 4))
184 body = addon._disk_html()
185 assert "free of" in body and "floor" in body
186
187
188def test_the_page_says_so_when_ingests_are_declining(monkeypatch):
189 monkeypatch.setattr(addon, "_disk", lambda: (1024 ** 4, 1024 ** 4 - 1, 1))
190 assert "declining" in addon._disk_html()