/*
* 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 =
'
' +
'
' +
'
' +
'
Download History ' +
'
' +
'
' +
'
× ' +
'
' +
'
' +
'
' +
'' +
'
';
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 };
})();