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:
parent
f380e8ac27
commit
b55c245fe6
5 changed files with 151 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
62
tests/test_video_download_history_actions.py
Normal file
62
tests/test_video_download_history_actions.py
Normal 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
|
||||
|
|
@ -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">↻ Re-download</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
return '<div class="vdh-row" data-vdh-row data-id="' + esc(it.id) + '">' +
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue