OverviewFilesCommitsIssuesDocsReleasesPeers

stremio-vsembed commits / e1a7094

Delete a copy found to be bad (0.24.0)
labs#13 part 4. POST /web/delete, behind a confirm() on the watch page and on title pages. Immediate (chosen over a trash folder), so the rails are scope: - Only copies this addon fetched: a queue entry must name the same FILE (_fetched_dests). Ownership is by destination, like the dest lock, so a tt… Kodi job's copy is ours too. The rest of the ~745 GB library is out of reach; a title TMDB cannot identify right now is undeletable for the moment. - 409 while a job holds the dest lock. - Removes the mp4, exactly the sidecars _sidecar_paths writes (<stem>.<code>[.<n>].srt, matched by pattern, not a bare glob), the verification record and legacy mp4-cache copies; rmdirs season/show folders only if empty and never Shows/ or Movies/. - Queue entries -> "deleted" (not resumed); history line with bytes freed. - No re-fetch block (user's choice): title page offers Download again. - _notify_jellyfin hook in place (no-op until part 5). 22 tests. Suite 441. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
e1a70942a0ef5ebf0d100c264f9ad41a16fda62c
by plankatron <fleeky@gmail.com> · 2026-10-05T00:49:43.000Z

Changed files (2) +429 -5

addon.py +168 -5
……@@ -619,7 +619,7 @@ TV_GENRES = [
619619
620620MANIFEST = {
621621 "id": "org.tabertron.vsembed",
622 "version": "0.23.0",
622 "version": "0.24.0",
623623 "name": "Tabertron VsEmbed",
624624 "description": "Self-hosted extractor with TMDB browse + search, played via vidcore and HdHub",
625625 "types": ["movie", "series"],
……@@ -3984,6 +3984,8 @@ border:1px solid var(--line);border-radius:.3rem;background:var(--card);color:in
39843984select,button{font:inherit;padding:.4rem .7rem;border:1px solid var(--line);
39853985border-radius:.3rem;background:var(--card);color:inherit;cursor:pointer}
39863986button.go{background:var(--run);border-color:var(--run);color:#fff}
3987button.del{color:var(--bad);border-color:var(--bad)}
3988a.watch{font-size:.85rem;margin-left:.3rem}
39873989h1{font-size:1.4rem;margin:.5rem 0}
39883990h2{font-size:1.05rem;margin:1.2rem 0 .5rem}
39893991.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(8.5rem,1fr));gap:.8rem}
……@@ -4160,7 +4162,9 @@ def _web_header(show: dict) -> str:
41604162def _web_movie(tmdb: str) -> Response:
41614163 film = _tmdb_get(f"movie/{tmdb}")
41624164 state = _web_state("movie", tmdb)
4163 action = ("" if state["status"] in ("held", "running")
4165 action = ("" if state["status"] == "running"
4166 else _web_delete_form("movie", tmdb, 0, 0)
4167 if state["status"] == "held"
41644168 else _web_download_form("movie", tmdb))
41654169 body = (_web_header(film) + _web_low_space()
41664170 + f"<p>{_web_state_html(state, f'/web/watch/movie/{tmdb}')} "
……@@ -4188,6 +4192,7 @@ def _web_series(tmdb: str) -> Response:
41884192 running = any(_season_jobs.get(f"season:{ident}:{season}:{h}") == "running"
41894193 for h in VIDCORE_HEIGHTS)
41904194 rows, aired_missing = [], 0
4195 owned = set(_fetched_dests())
41914196 for ep in episodes:
41924197 num = ep.get("episode_number")
41934198 if not isinstance(num, int):
……@@ -4205,7 +4210,9 @@ def _web_series(tmdb: str) -> Response:
42054210 running = running or state["status"] == "running"
42064211 if state["status"] not in ("held", "running"):
42074212 aired_missing += 1
4208 action = ("" if state["status"] in ("held", "running")
4213 action = ("" if state["status"] == "running"
4214 else _web_delete_form("series", tmdb, season, num, owned)
4215 if state["status"] == "held"
42094216 else _web_download_form("series", tmdb, season, num))
42104217 rows.append(f"<tr><td class=n>{num}</td><td>{name}<br>"
42114218 f"<span class=mute>{air}</span></td>"
……@@ -4571,8 +4578,164 @@ def _web_watch(kind: str, tmdb: str, season: int = 0, episode: int = 0):
45714578 return _web_page(f"{title} {sub} — watch", body)
45724579
45734580
4574def _web_delete_form(kind: str, tmdb: str, season: int, episode: int) -> str:
4575 return "" # labs#13 part 4
4581# ---------------------------------- delete ----------------------------------
4582# Remove a copy found to be bad. Immediate -- chosen over a trash folder -- so
4583# the guard rails are about SCOPE. Only copies this addon fetched can go: the
4584# library is ~745 GB, nearly all of it arrived by other means, and nothing here
4585# may touch that. Ownership is by FILE, like the dest lock, because a Kodi
4586# cinemeta job (tt…) and a web job (tmdb:…) name the same destination.
4587#
4588# No re-fetch block afterwards (the user's choice): a later play or download
4589# simply fetches it again.
4590
4591def _fetched_dests() -> dict[str, list[str]]:
4592 """{absolute library path: [queue keys that fetched it]}.
4593
4594 A title TMDB cannot identify right now is left out, which makes it
4595 undeletable for the moment -- the safe direction for a destructive action.
4596 """
4597 out: dict[str, list[str]] = {}
4598 for key, entry in _load_job_queue().items():
4599 args = entry.get("args") or []
4600 if len(args) < 5:
4601 continue
4602 kind, ident = args[0], args[1]
4603 season, episode = int(args[2]), int(args[3])
4604 meta = _library_meta(kind, ident, season, episode)
4605 if not meta:
4606 continue
4607 dest = _library_path(kind, meta["title"], meta["year"], meta["tmdb_id"],
4608 season, episode, meta["episode_title"])
4609 out.setdefault(os.path.abspath(dest), []).append(key)
4610 return out
4611
4612
4613def _sidecars_of(path: str) -> list[str]:
4614 """Exactly the names _sidecar_paths writes for this copy:
4615 `<stem>.<code>.srt` and `<stem>.<code>.<n>.srt`. Matched by pattern, not
4616 a bare glob, so nothing that merely starts with the same name is caught."""
4617 folder = os.path.dirname(path)
4618 stem = os.path.splitext(os.path.basename(path))[0]
4619 pattern = re.compile(re.escape(stem) + r"\.[^./\\]+(\.\d+)?\.srt$")
4620 try:
4621 names = os.listdir(folder)
4622 except OSError:
4623 return []
4624 return [os.path.join(folder, n) for n in names if pattern.fullmatch(n)]
4625
4626
4627def _prune_empty_dirs(path: str) -> None:
4628 """Remove the season and show folders a delete leaves empty -- and stop at
4629 Shows/ or Movies/, which are never removed. rmdir refuses a non-empty
4630 folder, so this cannot take anything with it."""
4631 root = os.path.abspath(LIBRARY_ROOT)
4632 stops = {root, os.path.join(root, "Shows"), os.path.join(root, "Movies")}
4633 folder = os.path.dirname(os.path.abspath(path))
4634 while folder.startswith(root + os.sep) and folder not in stops:
4635 try:
4636 os.rmdir(folder)
4637 except OSError:
4638 return
4639 folder = os.path.dirname(folder)
4640
4641
4642def _delete_copy(kind: str, tmdb: str, season: int, episode: int) -> tuple[int, str]:
4643 """(http status, what happened). 200 on success."""
4644 ident = f"tmdb:{tmdb}"
4645 meta = _library_meta(kind, ident, season, episode)
4646 if not meta:
4647 return 404, "title not identifiable"
4648 dest = os.path.abspath(_library_path(kind, meta["title"], meta["year"],
4649 meta["tmdb_id"], season, episode,
4650 meta["episode_title"]))
4651 owners = _fetched_dests().get(dest)
4652 if not owners:
4653 return 404, "not a copy this addon fetched"
4654 lock = _dest_lock(dest)
4655 if not lock.acquire(blocking=False):
4656 return 409, "a download is writing this file"
4657 try:
4658 legacy = [p for h in VIDCORE_HEIGHTS
4659 for p in {_mp4_path(kind, ident, season, episode, h)}
4660 if os.path.exists(p)]
4661 if not os.path.exists(dest) and not legacy:
4662 return 404, "no copy on disk"
4663 freed = 0
4664 for p in ([dest] if os.path.exists(dest) else []) + _sidecars_of(dest) + legacy:
4665 try:
4666 freed += os.path.getsize(p)
4667 os.remove(p)
4668 log.info("delete: removed %s", p)
4669 except OSError as exc:
4670 log.warning("delete: could not remove %s (%s)", p, exc)
4671 _forget_verified(dest)
4672 _prune_empty_dirs(dest)
4673 with _mp4_jobs_guard:
4674 for key in owners:
4675 _set_job_state(key, "deleted")
4676 _append_history({
4677 "ts": time.time(), "key": owners[0], "kind": kind, "ident": ident,
4678 "season": season, "episode": episode, "title": meta["title"],
4679 "label": (f"S{season:02d}E{episode:02d}" if kind == "series"
4680 else "movie"),
4681 "outcome": "deleted", "source": None, "bytes": freed,
4682 "took": 0, "dest": dest})
4683 finally:
4684 lock.release()
4685 _notify_jellyfin(dest, "Deleted")
4686 return 200, f"deleted {os.path.basename(dest)}"
4687
4688
4689def _notify_jellyfin(path: str, update_type: str) -> None:
4690 """Tell Jellyfin a library file changed. Filled in by labs#13 part 5."""
4691
4692
4693def _web_delete_form(kind: str, tmdb: str, season: int, episode: int,
4694 owned: Optional[set] = None) -> str:
4695 """The Delete button, only for a copy this addon fetched. `owned` lets a
4696 page that renders many rows compute _fetched_dests once."""
4697 meta = _library_meta(kind, f"tmdb:{tmdb}", season, episode)
4698 if not meta:
4699 return ""
4700 dest = os.path.abspath(_library_path(kind, meta["title"], meta["year"],
4701 meta["tmdb_id"], season, episode,
4702 meta["episode_title"]))
4703 if dest not in (owned if owned is not None else _fetched_dests()):
4704 return ""
4705 name = meta["title"] + (f" S{season:02d}E{episode:02d}" if kind == "series" else "")
4706 prompt = json.dumps(f"Delete {name}? The file and its subtitles are removed "
4707 "now; this cannot be undone.")
4708 return ("<form class=dl method=post action='/web/delete' "
4709 f"onsubmit='return confirm({html.escape(prompt, quote=True)})'>"
4710 f"<input type=hidden name='kind' value='{kind}'>"
4711 f"<input type=hidden name='tmdb' value='{tmdb}'>"
4712 f"<input type=hidden name='season' value='{season}'>"
4713 f"<input type=hidden name='episode' value='{episode}'>"
4714 "<button class=del>Delete</button></form>")
4715
4716
4717@app.route("/web/delete", methods=["POST"])
4718def web_delete():
4719 """POST only, like every action here: a stray GET must never delete."""
4720 f = request.form
4721 kind, tmdb = f.get("kind") or "", f.get("tmdb") or ""
4722 if kind not in ("movie", "series") or not tmdb.isdigit():
4723 abort(400)
4724 try:
4725 season = int(f.get("season") or 0)
4726 episode = int(f.get("episode") or 0)
4727 except ValueError:
4728 abort(400)
4729 if kind == "movie":
4730 season = episode = 0
4731 status, what = _delete_copy(kind, tmdb, season, episode)
4732 log.info("web: delete %s:%s:%s:%s -> %s %s",
4733 kind, tmdb, season, episode, status, what)
4734 if status != 200:
4735 return _web_page("Not deleted", f"<p class=warn>{html.escape(what)}</p>",
4736 status=status)
4737 back = f"/web/title/{kind}/{tmdb}" + (f"?s={season}" if kind == "series" else "")
4738 return redirect(back, code=303)
45764739
45774740
45784741@app.route("/web/watch/movie/<tmdb>")
test_web_delete.py +261 -0
……@@ -0,0 +1,261 @@
1"""Delete a copy found to be bad (labs#13). Immediate -- the user chose that
2over a trash folder -- so the guard rails are about SCOPE, not undo:
3
4 - only copies this addon fetched (a queue entry names the same file). The
5 library is ~745 GB, the overwhelming majority not ours; this button must
6 not be able to touch any of it.
7 - never while a job holds the file (its dest lock).
8 - everything the copy brought: the mp4, its .srt sidecars, its verification
9 record, and any legacy mp4-cache copy of the same title.
10 - folders removed only if that leaves them EMPTY, and never above the library.
11 - recorded in history. No re-fetch block (the user's choice).
12
13POST only.
14
15Run: .venv/bin/python3 -m pytest test_web_delete.py -q
16"""
17
18import json
19import os
20
21import pytest
22
23import addon
24
25
26TMDB = {
27 "tv/1": {"name": "Show", "first_air_date": "2020-01-01",
28 "seasons": [{"season_number": 2}]},
29 "tv/1/season/2": {"episodes": [
30 {"episode_number": 6, "name": "Six", "air_date": "2020-01-01"},
31 {"episode_number": 7, "name": "Seven", "air_date": "2020-01-01"}]},
32 "tv/1/season/2/episode/6": {"name": "Six", "runtime": 30},
33 "tv/1/season/2/episode/7": {"name": "Seven", "runtime": 30},
34 "movie/2": {"title": "Film", "release_date": "2021-05-05", "runtime": 30},
35}
36
37
38@pytest.fixture(autouse=True)
39def isolated(tmp_path, monkeypatch):
40 monkeypatch.setattr(addon, "LIBRARY_ROOT", str(tmp_path / "lib"))
41 monkeypatch.setattr(addon, "INGEST_QUEUE_FILE", str(tmp_path / "queue.json"))
42 monkeypatch.setattr(addon, "INGEST_HISTORY_FILE", str(tmp_path / "history.jsonl"))
43 monkeypatch.setattr(addon, "LIBRARY_VERIFIED_FILE", str(tmp_path / "verified.json"))
44 monkeypatch.setattr(addon, "MP4_DIR", str(tmp_path / "mp4-cache"))
45 monkeypatch.setattr(addon, "_duration_cache", {})
46 monkeypatch.setattr(addon, "_quality_cache", {})
47 monkeypatch.setattr(addon, "_library_meta_cache", {})
48 monkeypatch.setattr(addon, "_mp4_jobs", {})
49 monkeypatch.setattr(addon, "_dest_locks", {})
50 monkeypatch.setattr(addon, "_enough_space", lambda: True)
51 monkeypatch.setattr(addon, "_tmdb_get", lambda path, params=None: TMDB[path])
52 monkeypatch.setattr(addon, "_media_duration", lambda p: 1800.0)
53 monkeypatch.setattr(addon, "_ffprobe_file", lambda p: {"streams": [], "format": {}})
54 notified = []
55 monkeypatch.setattr(addon, "_notify_jellyfin",
56 lambda path, kind: notified.append((path, kind)),
57 raising=False)
58 yield notified
59
60
61@pytest.fixture
62def client():
63 return addon.app.test_client()
64
65
66def _hold(kind, season=0, episode=0, tmdb="1", ours=True, ident=None):
67 ident = ident or f"tmdb:{tmdb}"
68 meta = addon._library_meta(kind, f"tmdb:{tmdb}", season, episode)
69 path = addon._library_path(kind, meta["title"], meta["year"], meta["tmdb_id"],
70 season, episode, meta["episode_title"])
71 os.makedirs(os.path.dirname(path), exist_ok=True)
72 with open(path, "wb") as f:
73 f.write(b"x" * 4096)
74 if ours:
75 addon._set_job_state(addon._mp4_key(kind, ident, season, episode, 1080),
76 "done", args=(kind, ident, season, episode, 1080))
77 return path
78
79
80def _sidecars(path, *langs):
81 stem = os.path.splitext(path)[0]
82 out = []
83 for lang in langs:
84 p = f"{stem}.{lang}.srt"
85 with open(p, "w") as f:
86 f.write("1\n00:00:01,000 --> 00:00:02,000\nhi\n")
87 out.append(p)
88 return out
89
90
91def _post(client, kind="series", tmdb="1", season="2", episode="6"):
92 return client.post("/web/delete", data={"kind": kind, "tmdb": tmdb,
93 "season": season, "episode": episode})
94
95
96def test_delete_removes_the_copy_and_its_sidecars(client):
97 path = _hold("series", 2, 6)
98 subs = _sidecars(path, "en", "pt-br")
99 r = _post(client)
100 assert r.status_code == 303
101 assert r.headers["Location"].endswith("/web/title/series/1?s=2")
102 assert not os.path.exists(path)
103 assert not any(os.path.exists(s) for s in subs)
104
105
106def test_delete_leaves_other_episodes_and_their_folder(client):
107 six = _hold("series", 2, 6)
108 seven = _hold("series", 2, 7)
109 _post(client)
110 assert not os.path.exists(six)
111 assert os.path.exists(seven)
112
113
114def test_delete_does_not_touch_a_similarly_named_sidecar_of_another_episode(client):
115 six = _hold("series", 2, 6)
116 seven = _hold("series", 2, 7)
117 (other,) = _sidecars(seven, "en")
118 _post(client)
119 assert os.path.exists(other)
120
121
122def test_emptied_folders_are_removed_but_never_the_library_root(client):
123 path = _hold("series", 2, 6)
124 season_dir = os.path.dirname(path)
125 show_dir = os.path.dirname(season_dir)
126 _post(client)
127 assert not os.path.exists(season_dir)
128 assert not os.path.exists(show_dir)
129 assert os.path.isdir(os.path.join(addon.LIBRARY_ROOT, "Shows"))
130
131
132def test_delete_forgets_the_verification_record(client):
133 path = _hold("series", 2, 6)
134 addon._record_verified(path, 1800.0, 1800.0)
135 _post(client)
136 assert os.path.abspath(path) not in addon._load_verified()
137
138
139def test_delete_removes_a_legacy_cache_copy_of_the_same_title(client):
140 path = _hold("series", 2, 6)
141 legacy = addon._mp4_path("series", "tmdb:1", 2, 6, 720)
142 os.makedirs(os.path.dirname(legacy), exist_ok=True)
143 with open(legacy, "wb") as f:
144 f.write(b"y")
145 _post(client)
146 assert not os.path.exists(legacy)
147
148
149def test_delete_is_recorded_in_history(client):
150 path = _hold("series", 2, 6)
151 _post(client)
152 with open(addon.INGEST_HISTORY_FILE) as f:
153 (h,) = [json.loads(line) for line in f]
154 assert h["outcome"] == "deleted"
155 assert h["bytes"] == 4096
156 assert h["dest"] == path
157 assert h["label"] == "S02E06" and h["title"] == "Show"
158
159
160def test_delete_marks_the_queue_entry_and_it_is_not_resumed(client, monkeypatch):
161 _hold("series", 2, 6)
162 _post(client)
163 with open(addon.INGEST_QUEUE_FILE) as f:
164 assert json.load(f)["series:tmdb:1:2:6:1080"]["state"] == "deleted"
165 started = []
166 monkeypatch.setattr(addon, "_start_mp4_job", lambda *a, **k: started.append(a))
167 addon._resume_ingests()
168 assert started == []
169
170
171def test_a_copy_fetched_under_a_tt_ident_is_still_ours(client):
172 """Ownership is by FILE, like the dest lock: a Kodi cinemeta job (tt…)
173 names the same destination as the web's tmdb: one."""
174 path = _hold("series", 2, 6, ours=False)
175 addon._set_job_state("series:tt9:2:6:1080", "done",
176 args=("series", "tt9", 2, 6, 1080))
177 real = addon._library_meta
178 addon._library_meta_cache["series:tt9:2:6"] = real("series", "tmdb:1", 2, 6)
179 assert _post(client).status_code == 303
180 assert not os.path.exists(path)
181
182
183def test_a_copy_this_addon_did_not_fetch_cannot_be_deleted(client):
184 path = _hold("series", 2, 6, ours=False)
185 assert _post(client).status_code == 404
186 assert os.path.exists(path)
187
188
189def test_a_copy_being_written_cannot_be_deleted(client):
190 path = _hold("series", 2, 6)
191 lock = addon._dest_lock(path)
192 lock.acquire()
193 try:
194 assert _post(client).status_code == 409
195 assert os.path.exists(path)
196 finally:
197 lock.release()
198
199
200def test_deleting_what_is_not_there_is_404(client):
201 assert _post(client).status_code == 404
202
203
204def test_a_movie_delete_returns_to_the_movie(client):
205 path = _hold("movie", tmdb="2")
206 r = _post(client, kind="movie", tmdb="2", season="0", episode="0")
207 assert r.headers["Location"].endswith("/web/title/movie/2")
208 assert not os.path.exists(path)
209
210
211def test_delete_notifies_jellyfin(client, isolated):
212 path = _hold("series", 2, 6)
213 _post(client)
214 assert isolated == [(path, "Deleted")]
215
216
217@pytest.mark.parametrize("data", [
218 {"kind": "series", "tmdb": "../1", "season": "2", "episode": "6"},
219 {"kind": "anime", "tmdb": "1", "season": "2", "episode": "6"},
220 {"kind": "series", "tmdb": "1", "season": "x", "episode": "6"},
221])
222def test_bad_input_is_400(client, data):
223 path = _hold("series", 2, 6)
224 assert client.post("/web/delete", data=data).status_code == 400
225 assert os.path.exists(path)
226
227
228def test_a_get_can_never_delete(client):
229 path = _hold("series", 2, 6)
230 assert client.get("/web/delete?kind=series&tmdb=1&season=2&episode=6").status_code == 405
231 assert os.path.exists(path)
232
233
234# --- where the button appears ------------------------------------------------
235
236def test_watch_page_has_a_confirmed_delete(client):
237 _hold("series", 2, 6)
238 body = client.get("/web/watch/series/1/2/6").get_data(as_text=True)
239 assert "action='/web/delete'" in body
240 assert "confirm(" in body
241
242
243def test_title_page_offers_delete_for_our_copy_only(client):
244 _hold("series", 2, 6)
245 _hold("series", 2, 7, ours=False)
246 body = client.get("/web/title/series/1?s=2").get_data(as_text=True)
247 assert body.count("action='/web/delete'") == 1
248 assert "name='episode' value='6'" in body
249
250
251def test_watch_page_has_no_delete_for_a_copy_not_ours(client):
252 _hold("series", 2, 6, ours=False)
253 body = client.get("/web/watch/series/1/2/6").get_data(as_text=True)
254 assert "action='/web/delete'" not in body
255
256
257def test_after_delete_the_title_offers_download_again(client):
258 _hold("series", 2, 6)
259 _post(client)
260 body = client.get("/web/title/series/1?s=2").get_data(as_text=True)
261 assert "action='/web/download'" in body