video downloads: re-download + clear history actions

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/<id>) 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).
This commit is contained in:
BoulderBadgeDad 2026-06-26 09:44:56 -07:00
parent f380e8ac27
commit b55c245fe6
5 changed files with 151 additions and 0 deletions

View file

@ -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/<int:history_id>", 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

View file

@ -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."""

View file

@ -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

View file

@ -73,6 +73,7 @@
'<svg class="vdh-search-ic" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>' +
'<input type="text" class="vdh-search-input" data-vdh-search placeholder="Search history…" autocomplete="off" spellcheck="false">' +
'</div>' +
'<button class="vdh-clear" type="button" data-vdh-clear title="Clear the whole download history">Clear</button>' +
'</div>' +
'<div class="vdh-body" data-vdh-body></div>' +
'<div class="vdh-foot" data-vdh-foot hidden>' +
@ -86,6 +87,39 @@
var tab = e.target.closest('[data-vdh-tab]');
if (tab) { setTab(tab.getAttribute('data-vdh-tab')); return; }
if (e.target.closest('[data-vdh-more]')) { state.page++; load(true); return; }
// Re-download: forget this grab so the scans re-add + re-grab it.
var redl = e.target.closest('[data-vdh-redl]');
if (redl) {
e.stopPropagation();
redl.disabled = true;
fetch('/api/video/downloads/history/' + encodeURIComponent(redl.getAttribute('data-vdh-redl')),
{ method: 'DELETE', headers: { Accept: 'application/json' } })
.then(function (r) { return r.json(); })
.then(function (d) {
if (d && d.success) {
if (typeof showToast === 'function') showToast("Forgotten — it'll re-download on the next scan", 'success');
var r2 = redl.closest('[data-vdh-row]'); if (r2) r2.remove();
} else { redl.disabled = false; }
}).catch(function () { redl.disabled = false; });
return;
}
// Clear all history (guarded — it's permanent).
if (e.target.closest('[data-vdh-clear]')) {
var go = function () {
fetch('/api/video/downloads/history/clear',
{ method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: '{}' })
.then(function (r) { return r.json(); })
.then(function (d) {
if (typeof showToast === 'function') showToast('Cleared ' + ((d && d.removed) || 0) + ' from history', 'info');
state.page = 1; load();
});
};
if (typeof showConfirmDialog === 'function') {
showConfirmDialog({ title: 'Clear download history', message: 'Forget the entire download history? Items still wanted may re-download.', confirmText: 'Clear', destructive: true })
.then(function (ok) { if (ok) go(); });
} else if (window.confirm('Clear the entire download history?')) { go(); }
return;
}
var row = e.target.closest('[data-vdh-row]');
if (row) { row.classList.toggle('vdh-row--open'); }
});
@ -132,6 +166,10 @@
dl('Finished', fmtWhen(it.completed_at)) +
dl('Path', it.dest_path) +
(it.error ? '<div class="vdh-d vdh-d--err"><span class="vdh-d-k">Error</span><span class="vdh-d-v">' + esc(it.error) + '</span></div>' : '') +
'<div class="vdh-detail-act">' +
'<button class="vdh-redl" type="button" data-vdh-redl="' + esc(it.id) +
'" title="Forget this grab so it re-downloads on the next scan">&#8635; Re-download</button>' +
'</div>' +
'</div>';
return '<div class="vdh-row" data-vdh-row data-id="' + esc(it.id) + '">' +

View file

@ -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; }