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": False, "error": "not found"}), 404
|
||||||
return jsonify({"success": True, "item": d})
|
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"])
|
@bp.route("/downloads/quality", methods=["GET"])
|
||||||
def video_quality_profile():
|
def video_quality_profile():
|
||||||
from . import get_video_db
|
from . import get_video_db
|
||||||
|
|
|
||||||
|
|
@ -1645,6 +1645,33 @@ class VideoDatabase:
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
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:
|
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
|
"""The most recently completed grab of a type — the probe target for the smart
|
||||||
post-download scan. ``media_type`` ∈ movie|show|all."""
|
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>' +
|
'<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">' +
|
'<input type="text" class="vdh-search-input" data-vdh-search placeholder="Search history…" autocomplete="off" spellcheck="false">' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
|
'<button class="vdh-clear" type="button" data-vdh-clear title="Clear the whole download history">Clear</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="vdh-body" data-vdh-body></div>' +
|
'<div class="vdh-body" data-vdh-body></div>' +
|
||||||
'<div class="vdh-foot" data-vdh-foot hidden>' +
|
'<div class="vdh-foot" data-vdh-foot hidden>' +
|
||||||
|
|
@ -86,6 +87,39 @@
|
||||||
var tab = e.target.closest('[data-vdh-tab]');
|
var tab = e.target.closest('[data-vdh-tab]');
|
||||||
if (tab) { setTab(tab.getAttribute('data-vdh-tab')); return; }
|
if (tab) { setTab(tab.getAttribute('data-vdh-tab')); return; }
|
||||||
if (e.target.closest('[data-vdh-more]')) { state.page++; load(true); 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]');
|
var row = e.target.closest('[data-vdh-row]');
|
||||||
if (row) { row.classList.toggle('vdh-row--open'); }
|
if (row) { row.classList.toggle('vdh-row--open'); }
|
||||||
});
|
});
|
||||||
|
|
@ -132,6 +166,10 @@
|
||||||
dl('Finished', fmtWhen(it.completed_at)) +
|
dl('Finished', fmtWhen(it.completed_at)) +
|
||||||
dl('Path', it.dest_path) +
|
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>' : '') +
|
(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>';
|
'</div>';
|
||||||
|
|
||||||
return '<div class="vdh-row" data-vdh-row data-id="' + esc(it.id) + '">' +
|
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; }
|
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: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-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: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;
|
.vdh-search-input { flex: 1; min-width: 0; height: 100%; border: 0; background: transparent; outline: none;
|
||||||
color: #fff; font-size: 13px; }
|
color: #fff; font-size: 13px; }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue