diff --git a/api/video/downloads.py b/api/video/downloads.py index 4255a955..5e61a633 100644 --- a/api/video/downloads.py +++ b/api/video/downloads.py @@ -122,6 +122,32 @@ def register_routes(bp): save_source(db, body) # download_mode + hybrid_order (validated) return jsonify({"status": "saved"}) + @bp.route("/downloads/history", methods=["GET"]) + def video_downloads_history(): + """Paged permanent history of grabs (movies + episodes). ?kind=movie|show, + ?search=, ?outcome=, ?page=, ?limit=. Always returns counts for the tabs.""" + from . import get_video_db + try: + db = get_video_db() + kind = request.args.get("kind") + res = db.query_download_history( + kind=kind if kind in ("movie", "show") else None, + search=request.args.get("search", ""), + outcome=request.args.get("outcome") or None, + page=request.args.get("page", 1), limit=request.args.get("limit", 40)) + return jsonify({"success": True, "counts": db.download_history_counts(), **res}) + except Exception: + logger.exception("Failed to list video download history") + return jsonify({"success": False, "error": "Failed to load history"}), 500 + + @bp.route("/downloads/history/", methods=["GET"]) + def video_downloads_history_detail(history_id): + from . import get_video_db + d = get_video_db().download_history_detail(history_id) + if not d: + return jsonify({"success": False, "error": "not found"}), 404 + return jsonify({"success": True, "item": d}) + @bp.route("/downloads/quality", methods=["GET"]) def video_quality_profile(): from . import get_video_db diff --git a/tests/test_video_api.py b/tests/test_video_api.py index 6da0b9b3..8b302829 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -1081,3 +1081,38 @@ def test_enrichment_youtube_status_route(tmp_path): assert client.post("/api/video/enrichment/youtube/resume").get_json()["status"] == "running" # unknown service still 404s assert client.get("/api/video/enrichment/bogus/status").status_code == 404 + + +def test_download_history_endpoints(tmp_path): + client, videoapi = _make_client(tmp_path) + db = videoapi._video_db + db.record_download_history({ + "id": 1, "kind": "movie", "title": "Dune", "year": 2024, "status": "completed", + "release_title": "Dune.2024.2160p.x265", "dest_path": "/m/Dune.mkv", + "size_bytes": 9_000_000_000, "completed_at": "2026-06-20 10:30:00"}) + + # list + counts + r = client.get("/api/video/downloads/history") + assert r.status_code == 200 + body = r.get_json() + assert body["success"] and body["counts"]["movie"] == 1 + assert body["items"][0]["title"] == "Dune" + assert body["items"][0]["resolution"] == "2160p" + + # kind filter + assert client.get("/api/video/downloads/history?kind=show").get_json()["pagination"]["total_count"] == 0 + + # detail + hid = body["items"][0]["id"] + d = client.get(f"/api/video/downloads/history/{hid}").get_json() + assert d["success"] and d["item"]["dest_path"] == "/m/Dune.mkv" + assert client.get("/api/video/downloads/history/99999").status_code == 404 + + +def test_download_history_routes_registered(): + from api.video import create_video_blueprint + app = Flask(__name__) + app.register_blueprint(create_video_blueprint(), url_prefix="/api/video") + rules = {r.rule for r in app.url_map.iter_rules()} + assert "/api/video/downloads/history" in rules + assert "/api/video/downloads/history/" in rules diff --git a/webui/index.html b/webui/index.html index 9eba9d38..9ae5ee89 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1331,6 +1331,10 @@
+
@@ -10432,6 +10436,7 @@ + diff --git a/webui/static/video/video-download-history.js b/webui/static/video/video-download-history.js new file mode 100644 index 00000000..5c2e26ce --- /dev/null +++ b/webui/static/video/video-download-history.js @@ -0,0 +1,253 @@ +/* + * SoulSync — Video Download History modal (isolated). + * + * A permanent, beautiful timeline of every grab SoulSync completed (movies + + * episodes), backed by /api/video/downloads/history. Self-contained: builds its + * own overlay DOM, no shell in index.html. Opened by [data-vdh-open]; rows expand + * in place to reveal the full rich detail (poster, release, source, path, codecs). + */ +(function () { + 'use strict'; + + var LIMIT = 40; + var state = { open: false, tab: 'all', search: '', page: 1, loading: false, + counts: { movie: 0, show: 0, total: 0 }, items: [], pages: 1 }; + var el = null, searchTimer = null; + + function esc(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + function fmtSize(b) { + b = Number(b) || 0; + if (b <= 0) return ''; + var u = ['B', 'KB', 'MB', 'GB', 'TB'], i = Math.floor(Math.log(b) / Math.log(1024)); + return (b / Math.pow(1024, i)).toFixed(i >= 3 ? 1 : 0) + ' ' + u[i]; + } + var MO = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + function fmtWhen(iso) { + if (!iso) return ''; + var p = String(iso).replace('T', ' ').split(/[- :]/); + if (p.length < 3) return esc(iso); + var mo = MO[(+p[1] || 1) - 1], d = +p[2], y = +p[0]; + var t = (p.length >= 5) ? (' · ' + p[3] + ':' + p[4]) : ''; + return mo + ' ' + d + ', ' + y + t; + } + function relDay(iso) { + if (!iso) return ''; + var p = String(iso).replace('T', ' ').split(/[- :]/); + if (p.length < 3) return ''; + return p[0] + '-' + p[1] + '-' + p[2]; + } + function dayLabel(iso) { + var p = String(iso || '').replace('T', ' ').split(/[- :]/); + if (p.length < 3) return 'Earlier'; + return MO[(+p[1] || 1) - 1] + ' ' + (+p[2]) + ', ' + p[0]; + } + + var OUTCOME = { + completed: ['Imported', 'vdh-oc--ok'], import_failed: ['Needs import', 'vdh-oc--warn'], + failed: ['Failed', 'vdh-oc--fail'], cancelled: ['Cancelled', 'vdh-oc--muted'], + }; + + function ensureDom() { + if (el) return el; + el = document.createElement('div'); + el.className = 'vdh-overlay'; + el.innerHTML = + ''; + document.body.appendChild(el); + + el.addEventListener('click', function (e) { + if (e.target === el || e.target.closest('[data-vdh-close]')) { close(); return; } + 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; } + var row = e.target.closest('[data-vdh-row]'); + if (row) { row.classList.toggle('vdh-row--open'); } + }); + var si = el.querySelector('[data-vdh-search]'); + si.addEventListener('input', function () { + if (searchTimer) clearTimeout(searchTimer); + searchTimer = setTimeout(function () { + state.search = si.value.trim(); state.page = 1; load(false); + }, 250); + }); + return el; + } + + function setTab(tab) { + if (tab === state.tab) return; + state.tab = tab; state.page = 1; + var tabs = el.querySelectorAll('[data-vdh-tab]'); + for (var i = 0; i < tabs.length; i++) + tabs[i].classList.toggle('vdh-tab--on', tabs[i].getAttribute('data-vdh-tab') === tab); + load(false); + } + + function rowHtml(it) { + var isShow = it.kind === 'show'; + var oc = OUTCOME[it.outcome] || OUTCOME.completed; + var sxe = (it.season_number != null && it.episode_number != null) + ? 'S' + String(it.season_number).padStart(2, '0') + 'E' + String(it.episode_number).padStart(2, '0') : ''; + var sub = [sxe, it.year, it.quality_label, it.resolution, it.video_codec, fmtSize(it.size_bytes)] + .filter(Boolean).join(' · '); + var poster = it.poster_url + ? '' + : '' + (isShow ? '📺' : '🎬') + ''; + + function dl(label, val) { + return val ? '
' + label + '' + esc(val) + '
' : ''; + } + var detail = + '
' + + dl('Release', it.release_title) + + dl('Source', [it.source, it.username].filter(Boolean).join(' · ')) + + dl('Codec', [it.resolution, it.video_codec].filter(Boolean).join(' · ')) + + dl('Size', fmtSize(it.size_bytes)) + + dl('Grabbed', fmtWhen(it.grabbed_at)) + + dl('Finished', fmtWhen(it.completed_at)) + + dl('Path', it.dest_path) + + (it.error ? '
Error' + esc(it.error) + '
' : '') + + '
'; + + return '
' + + '
' + + poster + + '
' + + '
' + esc(it.title || it.release_title || 'Unknown') + + (sxe ? ' ' + esc(sxe) + '' : '') + '
' + + '
' + esc(sub) + '
' + + '
' + + '
' + + '' + oc[0] + '' + + '' + esc(fmtWhen(it.completed_at)) + '' + + '' + + '
' + + '
' + detail + + '
'; + } + + function render() { + var body = el.querySelector('[data-vdh-body]'); + if (!state.items.length) { + body.innerHTML = '
' + + '
📦
' + + '
' + (state.search ? 'No matches' : 'Nothing here yet') + '
' + + '
' + (state.search ? 'Try a different search.' : 'Grabs you complete will be recorded here forever.') + '
'; + return; + } + // Group rows under day headers (timeline feel). + var html = '', lastDay = null; + state.items.forEach(function (it) { + var d = relDay(it.completed_at || it.grabbed_at); + if (d !== lastDay) { + lastDay = d; + html += '
' + esc(dayLabel(it.completed_at || it.grabbed_at)) + '
'; + } + html += rowHtml(it); + }); + body.innerHTML = html; + } + + function setCounts(c) { + state.counts = c || state.counts; + var q = function (s) { return el.querySelector(s); }; + q('[data-vdh-c-all]').textContent = state.counts.total || 0; + q('[data-vdh-c-movie]').textContent = state.counts.movie || 0; + q('[data-vdh-c-show]').textContent = state.counts.show || 0; + var sub = q('[data-vdh-sub]'); + sub.textContent = (state.counts.total || 0) + ' grab' + (state.counts.total === 1 ? '' : 's') + + ' · ' + (state.counts.movie || 0) + ' movies · ' + (state.counts.show || 0) + ' episodes'; + updateBadge(state.counts.total || 0); + } + + function updateBadge(n) { + document.querySelectorAll('[data-vdh-count]').forEach(function (b) { + b.textContent = n; b.hidden = !n; + }); + } + + function load(append) { + if (state.loading) return; + state.loading = true; + var body = el.querySelector('[data-vdh-body]'); + if (!append) body.classList.add('vdh-body--loading'); + var params = new URLSearchParams({ page: state.page, limit: LIMIT, search: state.search }); + if (state.tab !== 'all') params.set('kind', state.tab); + fetch('/api/video/downloads/history?' + params.toString(), { headers: { Accept: 'application/json' } }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { + state.loading = false; + body.classList.remove('vdh-body--loading'); + if (!d || !d.success) { if (!append) { state.items = []; render(); } return; } + setCounts(d.counts); + state.pages = (d.pagination && d.pagination.total_pages) || 1; + state.items = append ? state.items.concat(d.items || []) : (d.items || []); + render(); + var foot = el.querySelector('[data-vdh-foot]'); + foot.hidden = state.page >= state.pages; + }) + .catch(function () { + state.loading = false; body.classList.remove('vdh-body--loading'); + if (!append) { state.items = []; render(); } + }); + } + + function open() { + ensureDom(); + state.open = true; state.page = 1; state.search = ''; + var si = el.querySelector('[data-vdh-search]'); if (si) si.value = ''; + el.classList.add('vdh-overlay--on'); + document.body.classList.add('vdh-locked'); + load(false); + } + function close() { + if (!el) return; + state.open = false; + el.classList.remove('vdh-overlay--on'); + document.body.classList.remove('vdh-locked'); + } + + // Keep the header badge fresh (cheap counts call) without opening the modal. + function refreshCount() { + fetch('/api/video/downloads/history?limit=1', { headers: { Accept: 'application/json' } }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { if (d && d.counts) updateBadge(d.counts.total || 0); }) + .catch(function () {}); + } + + document.addEventListener('click', function (e) { + if (e.target.closest('[data-vdh-open]')) { e.preventDefault(); open(); } + }); + document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && state.open) close(); }); + // Refresh the badge when the downloads page is shown. + document.addEventListener('soulsync:video-page-shown', function (e) { + if (e.detail === 'video-downloads') refreshCount(); + }); + + window.VideoDownloadHistory = { open: open, close: close, refreshCount: refreshCount }; +})(); diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 180ebe6a..7f0866f5 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -3643,3 +3643,108 @@ body[data-side="video"] #soulsync-toggle { display: none; } background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.14); width: 90px; } .vimp-ep-field--wide input { width: 100%; } .vimp-modal-foot { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 20px 18px; border-top: 1px solid rgba(255, 255, 255, 0.06); margin-top: 8px; } + +/* ── Download History modal (.vdh-*) — permanent timeline of every grab ───── */ +.adl-history-btn { display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 13px; + border-radius: 10px; cursor: pointer; font-size: 12.5px; font-weight: 700; + color: rgba(255, 255, 255, 0.75); background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); transition: all 0.18s ease; } +.adl-history-btn:hover { color: #fff; border-color: rgba(var(--accent-rgb, 88 101 242), 0.5); + background: rgba(var(--accent-rgb, 88 101 242), 0.1); } +.adl-history-n { font-size: 10.5px; font-weight: 800; min-width: 16px; text-align: center; padding: 1px 5px; + border-radius: 999px; background: rgba(var(--accent-rgb, 88 101 242), 0.28); color: #fff; } + +body.vdh-locked { overflow: hidden; } +.vdh-overlay { position: fixed; inset: 0; z-index: 4000; display: none; align-items: center; justify-content: center; + padding: 4vh 20px; background: rgba(6, 6, 9, 0.7); backdrop-filter: blur(8px); + opacity: 0; transition: opacity 0.2s ease; } +.vdh-overlay--on { display: flex; opacity: 1; } +.vdh-modal { --accent-rgb: var(--accent-rgb, 88, 101, 242); width: min(880px, 96vw); max-height: 92vh; + display: flex; flex-direction: column; border-radius: 20px; overflow: hidden; + background: linear-gradient(180deg, #17171d, #121217); + border: 1px solid rgba(255, 255, 255, 0.09); box-shadow: 0 30px 90px -20px rgba(0, 0, 0, 0.8); + transform: translateY(8px) scale(0.99); transition: transform 0.22s cubic-bezier(0.2, 0.8, 0.2, 1); } +.vdh-overlay--on .vdh-modal { transform: none; } + +.vdh-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; + padding: 22px 24px 16px; border-bottom: 1px solid rgba(255, 255, 255, 0.06); } +.vdh-title { margin: 0; font-size: 20px; font-weight: 800; color: #fff; letter-spacing: -0.01em; } +.vdh-sub { margin: 4px 0 0; font-size: 12.5px; color: rgba(255, 255, 255, 0.45); } +.vdh-close { flex: 0 0 auto; width: 34px; height: 34px; border-radius: 10px; cursor: pointer; font-size: 22px; line-height: 1; + color: rgba(255, 255, 255, 0.6); background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1); + transition: all 0.15s ease; } +.vdh-close:hover { color: #fff; background: rgba(255, 255, 255, 0.1); } + +.vdh-toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 14px 24px; } +.vdh-tabs { display: inline-flex; gap: 8px; } +.vdh-tab { display: inline-flex; align-items: center; gap: 7px; padding: 8px 15px; border-radius: 11px; cursor: pointer; + font-size: 12.5px; font-weight: 700; color: rgba(255, 255, 255, 0.6); + background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.1); + transition: color 0.2s, background 0.2s, border-color 0.2s, box-shadow 0.2s; } +.vdh-tab:hover { color: #fff; border-color: rgba(255, 255, 255, 0.2); } +.vdh-tab--on { color: #fff; background: rgba(var(--accent-rgb), 0.08); + border-color: rgba(var(--accent-rgb), 0.6); box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.16); } +.vdh-tab-n { font-size: 10.5px; font-weight: 800; min-width: 16px; text-align: center; padding: 1px 5px; + border-radius: 999px; background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.6); } +.vdh-tab--on .vdh-tab-n { background: rgba(var(--accent-rgb), 0.28); color: #fff; } +.vdh-search { position: relative; display: flex; align-items: center; gap: 9px; margin-left: auto; height: 38px; + flex: 1; min-width: 180px; max-width: 320px; padding: 0 13px; box-sizing: border-box; + background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 11px; + 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-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; } +.vdh-search-input::placeholder { color: rgba(255, 255, 255, 0.4); } + +.vdh-body { flex: 1; overflow-y: auto; padding: 6px 16px 8px; min-height: 160px; } +.vdh-body--loading { opacity: 0.5; } +.vdh-day { position: sticky; top: 0; z-index: 1; padding: 12px 8px 6px; font-size: 11px; font-weight: 800; + text-transform: uppercase; letter-spacing: 0.08em; color: rgba(255, 255, 255, 0.38); + background: linear-gradient(180deg, #15151b 60%, transparent); } + +.vdh-row { border-radius: 13px; border: 1px solid transparent; margin: 3px 0; cursor: pointer; + transition: background 0.15s, border-color 0.15s; } +.vdh-row:hover { background: rgba(255, 255, 255, 0.03); } +.vdh-row--open { background: rgba(255, 255, 255, 0.04); border-color: rgba(255, 255, 255, 0.08); } +.vdh-row-main { display: flex; align-items: center; gap: 13px; padding: 9px 12px; } +.vdh-poster { flex: 0 0 auto; width: 40px; height: 60px; border-radius: 7px; object-fit: cover; background: #0f0f14; + display: inline-flex; align-items: center; justify-content: center; font-size: 20px; overflow: hidden; } +.vdh-poster--none { color: rgba(255, 255, 255, 0.25); border: 1px solid rgba(255, 255, 255, 0.07); } +img.vdh-poster--none { font-size: 0; } +.vdh-row-info { flex: 1; min-width: 0; } +.vdh-row-title { font-size: 14px; font-weight: 700; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.vdh-sxe { font-size: 11px; font-weight: 800; color: rgb(var(--accent-rgb)); margin-left: 4px; } +.vdh-row-sub { margin-top: 3px; font-size: 11.5px; color: rgba(255, 255, 255, 0.45); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.vdh-row-right { flex: 0 0 auto; display: flex; align-items: center; gap: 12px; } +.vdh-oc { font-size: 10.5px; font-weight: 800; padding: 3px 9px; border-radius: 999px; white-space: nowrap; } +.vdh-oc--ok { color: #4ade80; background: rgba(34, 197, 94, 0.13); } +.vdh-oc--warn { color: #fbbf24; background: rgba(245, 158, 11, 0.14); } +.vdh-oc--fail { color: #f87171; background: rgba(248, 113, 113, 0.14); } +.vdh-oc--muted { color: rgba(255, 255, 255, 0.5); background: rgba(255, 255, 255, 0.06); } +.vdh-when { font-size: 11px; color: rgba(255, 255, 255, 0.4); white-space: nowrap; } +.vdh-chev { font-size: 18px; color: rgba(255, 255, 255, 0.3); transition: transform 0.18s ease; } +.vdh-row--open .vdh-chev { transform: rotate(90deg); } + +.vdh-detail { display: none; grid-template-columns: 1fr 1fr; gap: 4px 24px; padding: 4px 16px 16px 65px; } +.vdh-row--open .vdh-detail { display: grid; } +.vdh-d { display: flex; flex-direction: column; gap: 2px; padding: 6px 0; min-width: 0; + border-top: 1px solid rgba(255, 255, 255, 0.05); } +.vdh-d:nth-child(7), .vdh-d--err { grid-column: 1 / -1; } +.vdh-d-k { font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.06em; color: rgba(255, 255, 255, 0.35); } +.vdh-d-v { font-size: 12.5px; color: rgba(255, 255, 255, 0.8); word-break: break-word; } +.vdh-d--err .vdh-d-v { color: #fca5a5; } + +.vdh-empty { text-align: center; padding: 60px 20px; } +.vdh-empty-ic { font-size: 40px; margin-bottom: 12px; opacity: 0.8; } +.vdh-empty-t { font-size: 17px; font-weight: 800; color: #fff; margin-bottom: 6px; } +.vdh-empty-s { font-size: 13px; color: rgba(255, 255, 255, 0.45); } + +.vdh-foot { padding: 10px 24px 20px; text-align: center; } +.vdh-more { padding: 9px 22px; border-radius: 11px; cursor: pointer; font-size: 12.5px; font-weight: 700; + color: rgba(255, 255, 255, 0.8); background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.12); + transition: all 0.15s ease; } +.vdh-more:hover { color: #fff; background: rgba(255, 255, 255, 0.1); } +@media (max-width: 620px) { .vdh-detail { grid-template-columns: 1fr; padding-left: 16px; } }