From b55c245fe62365b60d0259bbe93127e1013e4058 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Fri, 26 Jun 2026 09:44:56 -0700 Subject: [PATCH] video downloads: re-download + clear history actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit after you delete a file, the scans dedup against download history so it won't re-grab — this gives you the escape hatch: - per-row 'Re-download' button in the History modal: forgets that grab (DELETE /downloads/history/) so the next scan re-adds + re-downloads it. - 'Clear' button: wipes the whole history (guarded confirm). db.delete_download_history / clear_download_history(kind?) + the two endpoints. tested (db methods + endpoints). --- api/video/downloads.py | 15 +++++ database/video_database.py | 27 +++++++++ tests/test_video_download_history_actions.py | 62 ++++++++++++++++++++ webui/static/video/video-download-history.js | 38 ++++++++++++ webui/static/video/video-side.css | 9 +++ 5 files changed, 151 insertions(+) create mode 100644 tests/test_video_download_history_actions.py diff --git a/api/video/downloads.py b/api/video/downloads.py index 5e61a633..1f0a6ac4 100644 --- a/api/video/downloads.py +++ b/api/video/downloads.py @@ -148,6 +148,21 @@ def register_routes(bp): return jsonify({"success": False, "error": "not found"}), 404 return jsonify({"success": True, "item": d}) + @bp.route("/downloads/history/", methods=["DELETE"]) + def video_downloads_history_delete(history_id): + """Forget one grab — the 'Re-download' action. Removing its history row lets the + scans re-add + re-grab it (useful after you've deleted the file).""" + from . import get_video_db + return jsonify({"success": get_video_db().delete_download_history(history_id)}) + + @bp.route("/downloads/history/clear", methods=["POST"]) + def video_downloads_history_clear(): + """Clear the permanent history (all, or one kind via {kind}).""" + from . import get_video_db + body = request.get_json(silent=True) or {} + n = get_video_db().clear_download_history(kind=body.get("kind")) + return jsonify({"success": True, "removed": n}) + @bp.route("/downloads/quality", methods=["GET"]) def video_quality_profile(): from . import get_video_db diff --git a/database/video_database.py b/database/video_database.py index c6826247..6655b0b3 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -1645,6 +1645,33 @@ class VideoDatabase: finally: conn.close() + def delete_download_history(self, history_id) -> bool: + """Forget one history grab — so the scans stop deduping against it and re-grab it + (the 'Re-download' action after you've deleted the file). Returns True if removed.""" + conn = self._get_connection() + try: + cur = conn.execute("DELETE FROM video_download_history WHERE id=?", (int(history_id),)) + conn.commit() + return cur.rowcount > 0 + except (sqlite3.Error, TypeError, ValueError): + return False + finally: + conn.close() + + def clear_download_history(self, kind=None) -> int: + """Clear the permanent history — all of it, or just one kind ('movie'|'show'| + 'youtube'). Returns the number of rows removed.""" + conn = self._get_connection() + try: + if kind in ("movie", "show", "youtube"): + cur = conn.execute("DELETE FROM video_download_history WHERE kind=?", (kind,)) + else: + cur = conn.execute("DELETE FROM video_download_history") + conn.commit() + return cur.rowcount + finally: + conn.close() + def latest_completed_download(self, media_type: str = "all") -> dict | None: """The most recently completed grab of a type — the probe target for the smart post-download scan. ``media_type`` ∈ movie|show|all.""" diff --git a/tests/test_video_download_history_actions.py b/tests/test_video_download_history_actions.py new file mode 100644 index 00000000..155163a3 --- /dev/null +++ b/tests/test_video_download_history_actions.py @@ -0,0 +1,62 @@ +"""Download-history actions: forget one grab (the 'Re-download' button) + clear the whole +history. Removing a history row lets the scans re-add + re-grab the item.""" + +from __future__ import annotations + +import pytest + +from database.video_database import VideoDatabase + + +@pytest.fixture() +def db(tmp_path): + d = VideoDatabase(database_path=str(tmp_path / "video_library.db")) + d.record_download_history({"id": 1, "kind": "youtube", "source": "youtube", + "media_id": "v1", "status": "completed", "dest_path": "/a.mp4"}) + d.record_download_history({"id": 2, "kind": "youtube", "source": "youtube", + "media_id": "v2", "status": "completed", "dest_path": "/b.mp4"}) + d.record_download_history({"id": 3, "kind": "movie", "source": "soulseek", + "media_id": "9", "status": "completed", "dest_path": "/m.mkv"}) + return d + + +def _hist_ids(db): + return {r["media_id"] for r in db.query_download_history()["items"]} + + +def test_delete_one_history_entry_frees_it_to_redownload(db): + # the youtube dedup sees v1 + v2; forgetting v1's grab drops it from the dedup set + assert set(db.downloaded_youtube_video_ids()) == {"v1", "v2"} + target = next(r["id"] for r in db.query_download_history()["items"] if r["media_id"] == "v1") + assert db.delete_download_history(target) is True + assert set(db.downloaded_youtube_video_ids()) == {"v2"} # v1 will re-grab + assert db.delete_download_history(999999) is False # missing → no-op + + +def test_clear_history_all_and_by_kind(db): + assert db.clear_download_history(kind="youtube") == 2 # just the youtube grabs + assert _hist_ids(db) == {"9"} # movie survives + assert db.clear_download_history() == 1 # clear the rest + assert _hist_ids(db) == set() + + +def test_history_action_endpoints(tmp_path): + from flask import Flask + import api.video as videoapi + db = VideoDatabase(database_path=str(tmp_path / "video_library.db")) + hid = db.record_download_history({"id": 1, "kind": "youtube", "source": "youtube", + "media_id": "v1", "status": "completed", "dest_path": "/a.mp4"}) + db.record_download_history({"id": 2, "kind": "youtube", "source": "youtube", + "media_id": "v2", "status": "completed", "dest_path": "/b.mp4"}) + videoapi._video_db = db + app = Flask(__name__) + app.register_blueprint(videoapi.create_video_blueprint(), url_prefix="/api/video") + client = app.test_client() + try: + assert client.delete("/api/video/downloads/history/%d" % hid).get_json()["success"] is True + assert set(db.downloaded_youtube_video_ids()) == {"v2"} + r = client.post("/api/video/downloads/history/clear", json={}).get_json() + assert r["success"] and r["removed"] == 1 # v2 left + assert db.downloaded_youtube_video_ids() == [] + finally: + videoapi._video_db = None diff --git a/webui/static/video/video-download-history.js b/webui/static/video/video-download-history.js index 5c2e26ce..e0c8b4df 100644 --- a/webui/static/video/video-download-history.js +++ b/webui/static/video/video-download-history.js @@ -73,6 +73,7 @@ '' + '' + '' + + '' + '' + '
' + ''; return '
' + diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 83c41b84..5877e8d8 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -3893,6 +3893,15 @@ body.vdh-locked { overflow: hidden; } transition: border-color 0.2s, box-shadow 0.2s; } .vdh-search:focus-within { border-color: rgba(var(--accent-rgb), 0.6); box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.16); } .vdh-search-ic { flex: 0 0 auto; color: rgba(255, 255, 255, 0.4); } +.vdh-clear { flex: 0 0 auto; height: 38px; padding: 0 16px; border-radius: 11px; cursor: pointer; box-sizing: border-box; + background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.75); + font-size: 12.5px; font-weight: 700; transition: all 0.15s; } +.vdh-clear:hover { background: rgba(239, 68, 68, 0.85); border-color: transparent; color: #fff; } +.vdh-detail-act { grid-column: 1 / -1; margin-top: 8px; } +.vdh-redl { padding: 7px 14px; border-radius: 9px; cursor: pointer; font-size: 12.5px; font-weight: 700; transition: all 0.15s; + background: rgba(var(--accent-rgb), 0.16); border: 1px solid rgba(var(--accent-rgb), 0.4); color: #fff; } +.vdh-redl:hover { background: rgba(var(--accent-rgb), 0.32); } +.vdh-redl:disabled { opacity: 0.5; cursor: default; } .vdh-search:focus-within .vdh-search-ic { color: rgba(var(--accent-rgb), 0.95); } .vdh-search-input { flex: 1; min-width: 0; height: 100%; border: 0; background: transparent; outline: none; color: #fff; font-size: 13px; }