| … | … | @@ -619,7 +619,7 @@ TV_GENRES = [ |
| 619 | 619 | |
| 620 | 620 | MANIFEST = { |
| 621 | 621 | "id": "org.tabertron.vsembed", |
| 622 | "version": "0.23.0", | |
| 622 | "version": "0.24.0", | |
| 623 | 623 | "name": "Tabertron VsEmbed", |
| 624 | 624 | "description": "Self-hosted extractor with TMDB browse + search, played via vidcore and HdHub", |
| 625 | 625 | "types": ["movie", "series"], |
| … | … | @@ -3984,6 +3984,8 @@ border:1px solid var(--line);border-radius:.3rem;background:var(--card);color:in |
| 3984 | 3984 | select,button{font:inherit;padding:.4rem .7rem;border:1px solid var(--line); |
| 3985 | 3985 | border-radius:.3rem;background:var(--card);color:inherit;cursor:pointer} |
| 3986 | 3986 | button.go{background:var(--run);border-color:var(--run);color:#fff} |
| 3987 | button.del{color:var(--bad);border-color:var(--bad)} | |
| 3988 | a.watch{font-size:.85rem;margin-left:.3rem} | |
| 3987 | 3989 | h1{font-size:1.4rem;margin:.5rem 0} |
| 3988 | 3990 | h2{font-size:1.05rem;margin:1.2rem 0 .5rem} |
| 3989 | 3991 | .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: |
| 4160 | 4162 | def _web_movie(tmdb: str) -> Response: |
| 4161 | 4163 | film = _tmdb_get(f"movie/{tmdb}") |
| 4162 | 4164 | 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" | |
| 4164 | 4168 | else _web_download_form("movie", tmdb)) |
| 4165 | 4169 | body = (_web_header(film) + _web_low_space() |
| 4166 | 4170 | + f"<p>{_web_state_html(state, f'/web/watch/movie/{tmdb}')} " |
| … | … | @@ -4188,6 +4192,7 @@ def _web_series(tmdb: str) -> Response: |
| 4188 | 4192 | running = any(_season_jobs.get(f"season:{ident}:{season}:{h}") == "running" |
| 4189 | 4193 | for h in VIDCORE_HEIGHTS) |
| 4190 | 4194 | rows, aired_missing = [], 0 |
| 4195 | owned = set(_fetched_dests()) | |
| 4191 | 4196 | for ep in episodes: |
| 4192 | 4197 | num = ep.get("episode_number") |
| 4193 | 4198 | if not isinstance(num, int): |
| … | … | @@ -4205,7 +4210,9 @@ def _web_series(tmdb: str) -> Response: |
| 4205 | 4210 | running = running or state["status"] == "running" |
| 4206 | 4211 | if state["status"] not in ("held", "running"): |
| 4207 | 4212 | 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" | |
| 4209 | 4216 | else _web_download_form("series", tmdb, season, num)) |
| 4210 | 4217 | rows.append(f"<tr><td class=n>{num}</td><td>{name}<br>" |
| 4211 | 4218 | f"<span class=mute>{air}</span></td>" |
| … | … | @@ -4571,8 +4578,164 @@ def _web_watch(kind: str, tmdb: str, season: int = 0, episode: int = 0): |
| 4571 | 4578 | return _web_page(f"{title} {sub} — watch", body) |
| 4572 | 4579 | |
| 4573 | 4580 | |
| 4574 | def _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 | ||
| 4591 | def _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 | ||
| 4613 | def _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 | ||
| 4627 | def _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 | ||
| 4642 | def _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 | ||
| 4689 | def _notify_jellyfin(path: str, update_type: str) -> None: | |
| 4690 | """Tell Jellyfin a library file changed. Filled in by labs#13 part 5.""" | |
| 4691 | ||
| 4692 | ||
| 4693 | def _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"]) | |
| 4718 | def 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) | |
| 4576 | 4739 | |
| 4577 | 4740 | |
| 4578 | 4741 | @app.route("/web/watch/movie/<tmdb>") |
| … | … | @@ -0,0 +1,261 @@ |
| 1 | """Delete a copy found to be bad (labs#13). Immediate -- the user chose that | |
| 2 | over 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 | ||
| 13 | POST only. | |
| 14 | ||
| 15 | Run: .venv/bin/python3 -m pytest test_web_delete.py -q | |
| 16 | """ | |
| 17 | ||
| 18 | import json | |
| 19 | import os | |
| 20 | ||
| 21 | import pytest | |
| 22 | ||
| 23 | import addon | |
| 24 | ||
| 25 | ||
| 26 | TMDB = { | |
| 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) | |
| 39 | def 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 | |
| 62 | def client(): | |
| 63 | return addon.app.test_client() | |
| 64 | ||
| 65 | ||
| 66 | def _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 | ||
| 80 | def _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 | ||
| 91 | def _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 | ||
| 96 | def 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 | ||
| 106 | def 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 | ||
| 114 | def 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 | ||
| 122 | def 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 | ||
| 132 | def 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 | ||
| 139 | def 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 | ||
| 149 | def 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 | ||
| 160 | def 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 | ||
| 171 | def 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 | ||
| 183 | def 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 | ||
| 189 | def 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 | ||
| 200 | def test_deleting_what_is_not_there_is_404(client): | |
| 201 | assert _post(client).status_code == 404 | |
| 202 | ||
| 203 | ||
| 204 | def 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 | ||
| 211 | def 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 | ]) | |
| 222 | def 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 | ||
| 228 | def 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 | ||
| 236 | def 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 | ||
| 243 | def 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 | ||
| 251 | def 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 | ||
| 257 | def 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 |