release_passwords (from spots: their NZB meta, title or text) was only read by the judge. The NZB now carries the most trusted one as <meta type="password">, which SABnzbd and NZBGet use to unpack; a spot's own NZB gets it added when its meta has none. The release details list all known passwords with their source, the list marks "Password known", and the web interface shows them instead of "not in the index". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
512 lines
19 KiB
JavaScript
512 lines
19 KiB
JavaScript
// Voyager 2's web interface (from voyager 1's). No framework; all Usenet-derived
|
||
// text is rendered with textContent, never as HTML.
|
||
|
||
const $ = (selector) => document.querySelector(selector)
|
||
const FILTERS = ['readable', 'complete', 'unlocked']
|
||
/** Text inputs that narrow a search: size bounds in MB. */
|
||
const FIELDS = ['min_size', 'max_size']
|
||
/** What a probed article was (probe_candidates.kind). */
|
||
const KIND_LABELS = {
|
||
archive: 'Archive start',
|
||
par2: 'Par2 index',
|
||
lone: 'Single file',
|
||
'7z-tail': '7z end',
|
||
}
|
||
const SOURCE_LABELS = {
|
||
stem: 'Filename',
|
||
subject: 'Subject',
|
||
post: 'Post id',
|
||
}
|
||
/** Where a known password came from (release_passwords.source). */
|
||
const PASSWORD_SOURCES = {
|
||
nzb: "from the spot's NZB",
|
||
'spot-title': "from the spot's title",
|
||
'spot-text': "from the spot's text",
|
||
}
|
||
/** Newznab categories (lib/newznab/categories.ts). */
|
||
const CATEGORIES = [
|
||
[2000, 'Movies', [[2030, 'SD'], [2040, 'HD'], [2045, 'UHD']]],
|
||
[3000, 'Audio', [[3010, 'MP3'], [3040, 'Lossless']]],
|
||
[5000, 'TV', [[5030, 'SD'], [5040, 'HD'], [5045, 'UHD']]],
|
||
[7000, 'Books', [[7020, 'Ebook']]],
|
||
[8000, 'Other', [[8010, 'Misc']]],
|
||
]
|
||
function categoryLabel(id) {
|
||
for (const [parent, name, subs] of CATEGORIES) {
|
||
if (Math.floor(id / 1000) * 1000 !== parent) continue
|
||
const sub = subs.find(([s]) => s === id)
|
||
return sub ? `${name} > ${sub[1]}` : name
|
||
}
|
||
return 'Other'
|
||
}
|
||
|
||
const state = {
|
||
q: '',
|
||
sort: 'newest',
|
||
source: '',
|
||
cat: '',
|
||
poster: '',
|
||
min_size: '',
|
||
max_size: '',
|
||
readable: false,
|
||
complete: false,
|
||
unlocked: false,
|
||
page: 1,
|
||
release: null,
|
||
}
|
||
let requestId = 0
|
||
let searchAbort = null
|
||
|
||
// --- Formatting ------------------------------------------------------------
|
||
|
||
function formatSize(bytes) {
|
||
if (!bytes) return '–'
|
||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||
let value = bytes
|
||
let unit = 0
|
||
while (value >= 1000 && unit < units.length - 1) {
|
||
value /= 1000
|
||
unit++
|
||
}
|
||
return `${value >= 100 || unit === 0 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`
|
||
}
|
||
|
||
const number = new Intl.NumberFormat()
|
||
|
||
function formatAge(date) {
|
||
if (!date) return ''
|
||
const seconds = (Date.now() - new Date(date).getTime()) / 1000
|
||
if (seconds < 90) return 'just now'
|
||
const steps = [
|
||
[60, 'minute'],
|
||
[3600, 'hour'],
|
||
[86400, 'day'],
|
||
[2592000, 'month'],
|
||
[31536000, 'year'],
|
||
]
|
||
let [size, name] = steps[0]
|
||
for (const step of steps) if (seconds >= step[0]) [size, name] = step
|
||
const n = Math.floor(seconds / size)
|
||
return `${n} ${name}${n === 1 ? '' : 's'} ago`
|
||
}
|
||
|
||
function el(tag, props = {}, ...children) {
|
||
const node = document.createElement(tag)
|
||
for (const [key, value] of Object.entries(props)) {
|
||
if (value === undefined || value === null || value === false) continue
|
||
if (key === 'class') node.className = value
|
||
else if (key === 'text') node.textContent = value
|
||
else if (key.startsWith('on')) node.addEventListener(key.slice(2), value)
|
||
else node.setAttribute(key, value === true ? '' : value)
|
||
}
|
||
for (const child of children.flat()) {
|
||
if (child === null || child === undefined || child === false) continue
|
||
node.append(child instanceof Node ? child : document.createTextNode(String(child)))
|
||
}
|
||
return node
|
||
}
|
||
|
||
function badge(text, tone = '', title) {
|
||
return el('span', { class: `badge${tone ? ` badge-${tone}` : ''}`, text, title })
|
||
}
|
||
|
||
// --- URL state -------------------------------------------------------------
|
||
|
||
function readUrl() {
|
||
const params = new URLSearchParams(location.search)
|
||
state.q = params.get('q') ?? ''
|
||
state.sort = params.get('sort') ?? 'newest'
|
||
state.source = params.get('source') ?? ''
|
||
state.cat = params.get('cat') ?? ''
|
||
state.poster = params.get('poster') ?? ''
|
||
for (const k of FIELDS) state[k] = params.get(k) ?? ''
|
||
for (const f of FILTERS) state[f] = params.get(f) === '1'
|
||
state.release = params.get('release') ? Number(params.get('release')) : null
|
||
}
|
||
|
||
function writeUrl(push = false) {
|
||
const params = new URLSearchParams()
|
||
if (state.q) params.set('q', state.q)
|
||
if (state.sort !== 'newest') params.set('sort', state.sort)
|
||
if (state.source) params.set('source', state.source)
|
||
if (state.cat) params.set('cat', state.cat)
|
||
if (state.poster) params.set('poster', state.poster)
|
||
for (const k of FIELDS) if (state[k]) params.set(k, state[k])
|
||
for (const f of FILTERS) if (state[f]) params.set(f, '1')
|
||
if (state.release) params.set('release', String(state.release))
|
||
const url = `${location.pathname}${params.size ? `?${params}` : ''}`
|
||
history[push ? 'pushState' : 'replaceState'](null, '', url)
|
||
}
|
||
|
||
function syncControls() {
|
||
$('#q').value = state.q
|
||
$('#sort').value = state.sort
|
||
$('#source').value = state.source
|
||
$('#cat').value = state.cat
|
||
for (const k of FIELDS) $(`#${k}`).value = state[k]
|
||
for (const f of FILTERS) {
|
||
document.querySelector(`[data-filter="${f}"]`).setAttribute('aria-pressed', String(state[f]))
|
||
}
|
||
$('#poster-filter').hidden = !state.poster
|
||
$('#poster-filter-label').textContent = `Poster: ${state.poster}`
|
||
}
|
||
|
||
// --- API -------------------------------------------------------------------
|
||
|
||
async function api(path, { signal } = {}) {
|
||
const response = await fetch(path, { headers: { accept: 'application/json' }, signal })
|
||
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`)
|
||
return response.json()
|
||
}
|
||
|
||
function searchParams(page) {
|
||
const params = new URLSearchParams({ sort: state.sort, page: String(page), per_page: '50' })
|
||
if (state.q) params.set('q', state.q)
|
||
if (state.source) params.set('source', state.source)
|
||
if (state.cat) params.set('cat', state.cat)
|
||
if (state.poster) params.set('poster', state.poster)
|
||
for (const k of FIELDS) if (state[k]) params.set(k, state[k])
|
||
for (const f of FILTERS) if (state[f]) params.set(f, '1')
|
||
return params
|
||
}
|
||
|
||
// --- Stats -----------------------------------------------------------------
|
||
|
||
async function loadStats() {
|
||
try {
|
||
const stats = await api('/api/v1/stats')
|
||
const readable = stats.releases ? Math.round((100 * stats.readableReleases) / stats.releases) : 0
|
||
// Estimated from Postgres statistics and a sample.
|
||
const about = stats.approximate ? '≈ ' : ''
|
||
const percent = ({ done, total }) => {
|
||
const p = total ? (100 * done) / total : 0
|
||
return `${p < 10 ? p.toFixed(1) : Math.round(p)}%`
|
||
}
|
||
const items = [
|
||
['Releases', about + number.format(stats.releases)],
|
||
['Readable', `${about}${readable}%`],
|
||
['Files', about + number.format(stats.files)],
|
||
['Size', about + formatSize(stats.size)],
|
||
['History', percent(stats.history)],
|
||
...(stats.pass.total < stats.history.total ? [['Pass', percent(stats.pass)]] : []),
|
||
['Live', formatAge(stats.tailIndexedAt) || '–'],
|
||
]
|
||
$('#stats').replaceChildren(...items.map(([dt, dd]) => el('div', {}, el('dt', { text: dt }), el('dd', { text: dd }))))
|
||
} catch {
|
||
// Stats are decoration; the page works without them.
|
||
}
|
||
}
|
||
|
||
// --- Results ---------------------------------------------------------------
|
||
|
||
function releaseRow(release) {
|
||
const row = $('#row-template').content.firstElementChild.cloneNode(true)
|
||
row.dataset.id = release.id
|
||
if (release.id === state.release) row.classList.add('selected')
|
||
|
||
const name = row.querySelector('.release-name')
|
||
name.textContent = release.name
|
||
if (!release.readable) name.classList.add('obfuscated')
|
||
|
||
const meta = [
|
||
formatAge(release.postedAt),
|
||
release.poster ? release.poster.replace(/\s*<.*>$/, '') : 'obfuscated poster',
|
||
release.groups?.length ? release.groups[0] + (release.groups.length > 1 ? ` +${release.groups.length - 1}` : '') : '',
|
||
].filter(Boolean)
|
||
row.querySelector('.release-meta').textContent = meta.join(' · ')
|
||
if (release.innerName) {
|
||
const inner = row.querySelector('.release-inner')
|
||
inner.textContent = release.innerName
|
||
inner.title = 'Found inside by reading its articles'
|
||
inner.hidden = false
|
||
}
|
||
|
||
const badges = row.querySelector('.badges')
|
||
const files = `${release.fileCount} ${release.fileCount === 1 ? 'file' : 'files'}`
|
||
if (release.complete) badges.append(badge(files, 'good', 'Complete'))
|
||
else if (release.fileTotal) badges.append(badge(`${release.fileCount}/${release.fileTotal}`, 'warn', 'Files present / announced'))
|
||
else badges.append(badge(files))
|
||
if (release.passworded && release.passwordKnown) badges.append(badge('Password known', 'warn', 'Its archive is encrypted; the password is in the NZB'))
|
||
else if (release.passworded) badges.append(badge('Password', 'bad', 'Its archive is encrypted: a password is needed'))
|
||
if (!release.settled) badges.append(badge('Collecting', 'accent', 'Recently seen: more files may still arrive'))
|
||
if (release.category && release.category !== 8010) badges.append(badge(categoryLabel(release.category), '', 'Category (from the name)'))
|
||
if (release.source && release.source !== 'stem' && release.source !== 'subject') {
|
||
badges.append(badge(SOURCE_LABELS[release.source] ?? release.source, 'accent', 'How this release was recognised'))
|
||
}
|
||
|
||
row.querySelector('.release-size').textContent = formatSize(release.size)
|
||
const nzb = row.querySelector('.nzb')
|
||
nzb.href = `/api/v1/releases/${release.id}/nzb`
|
||
row.querySelector('.release-main').addEventListener('click', () => openRelease(release.id, true))
|
||
return row
|
||
}
|
||
|
||
async function loadResults({ append = false } = {}) {
|
||
const id = ++requestId
|
||
if (!append) state.page = 1
|
||
// A newer search makes the running one pointless: cancel it.
|
||
searchAbort?.abort()
|
||
const abort = (searchAbort = new AbortController())
|
||
$('#more').hidden = true
|
||
if (!append) {
|
||
$('#summary').textContent = 'Searching…'
|
||
$('#empty').hidden = true
|
||
// The old rows no longer match the search; grey them out until the new ones arrive.
|
||
$('#list').classList.add('stale')
|
||
$('.results').setAttribute('aria-busy', 'true')
|
||
}
|
||
|
||
try {
|
||
const result = await api(`/api/v1/releases?${searchParams(state.page)}`, { signal: abort.signal })
|
||
if (id !== requestId) return // a newer search superseded this one
|
||
$('#list').classList.remove('stale')
|
||
$('.results').removeAttribute('aria-busy')
|
||
const rows = result.releases.map(releaseRow)
|
||
if (append) $('#list').append(...rows)
|
||
else $('#list').replaceChildren(...rows)
|
||
|
||
const shown = $('#list').children.length
|
||
$('#summary').textContent =
|
||
result.total === 0 ? '' : `${number.format(result.total)}${result.totalCapped ? '+' : ''} ${result.total === 1 ? 'release' : 'releases'}` +
|
||
(shown < result.total ? ` · showing ${number.format(shown)}` : '')
|
||
$('#empty').hidden = result.total !== 0
|
||
$('#more').hidden = shown >= result.total && !result.totalCapped
|
||
} catch (error) {
|
||
if (id !== requestId) return
|
||
$('#list').classList.remove('stale')
|
||
$('.results').removeAttribute('aria-busy')
|
||
$('#summary').textContent = `Search failed: ${error.message}`
|
||
}
|
||
}
|
||
|
||
let debounce
|
||
function scheduleSearch() {
|
||
clearTimeout(debounce)
|
||
debounce = setTimeout(() => {
|
||
writeUrl()
|
||
loadResults()
|
||
}, 220)
|
||
}
|
||
|
||
// --- Detail drawer ---------------------------------------------------------
|
||
|
||
function fact(label, value) {
|
||
if (value === null || value === undefined || value === '') return null
|
||
return el('div', {}, el('dt', { text: label }), el('dd', {}, value))
|
||
}
|
||
|
||
function section(title, note, content) {
|
||
return [el('h3', { class: 'section-title' }, title, note ? el('small', { text: note }) : null), content]
|
||
}
|
||
|
||
function renderDetail(release) {
|
||
$('#detail-name').textContent = release.name
|
||
$('#detail-source').textContent = `Recognised by ${SOURCE_LABELS[release.source] ?? release.source ?? 'filename'} · ${categoryLabel(release.category)}`
|
||
|
||
const actions = el(
|
||
'div',
|
||
{ class: 'actions' },
|
||
el('a', { class: 'button button-primary', href: `/api/v1/releases/${release.id}/nzb` },
|
||
el('span', { text: 'Download NZB' })),
|
||
el('button', {
|
||
class: 'button',
|
||
type: 'button',
|
||
text: 'Copy name',
|
||
onclick: async (event) => {
|
||
await navigator.clipboard?.writeText(release.name)
|
||
event.target.textContent = 'Copied'
|
||
setTimeout(() => (event.target.textContent = 'Copy name'), 1200)
|
||
},
|
||
})
|
||
)
|
||
|
||
const poster = release.poster
|
||
? el('button', {
|
||
class: 'link-button',
|
||
type: 'button',
|
||
text: release.poster.replace(/\s*<.*>$/, ''),
|
||
title: 'Show releases by this poster',
|
||
onclick: () => {
|
||
state.poster = release.poster
|
||
closeRelease()
|
||
syncControls()
|
||
writeUrl(true)
|
||
loadResults()
|
||
},
|
||
})
|
||
: 'Random per article (obfuscated)'
|
||
|
||
const facts = el(
|
||
'dl',
|
||
{ class: 'facts' },
|
||
fact('Size', formatSize(release.size)),
|
||
fact('Files', release.fileTotal ? `${release.fileCount} of ${release.fileTotal}` : `${release.fileCount} posted`),
|
||
fact('Articles', number.format(release.segments)),
|
||
fact('Posted', release.postedAt ? `${new Date(release.postedAt).toLocaleString()} (${formatAge(release.postedAt)})` : null),
|
||
fact('Poster', poster),
|
||
fact('Groups', release.groups?.join(', ')),
|
||
fact('Indexed', release.firstSeenAt ? `${new Date(release.firstSeenAt).toLocaleString()} (${formatAge(release.firstSeenAt)})` : null),
|
||
fact('Inside', release.innerName),
|
||
fact('Inspected', release.inspectedAt ? `${new Date(release.inspectedAt).toLocaleString()} (${formatAge(release.inspectedAt)})` : null)
|
||
)
|
||
|
||
const notices = []
|
||
if (!release.settledAt) {
|
||
notices.push(el('div', { class: 'notice notice-warn', text: 'Recently seen: more files of this release may still arrive. The NZB has what is indexed so far.' }))
|
||
}
|
||
const passwords = release.passwords ?? []
|
||
if (passwords.length) {
|
||
notices.push(el('div', { class: 'notice notice-warn' },
|
||
el('span', { text: `${passwords.length === 1 ? 'Password' : 'Passwords'} (the first is in the NZB): ` }),
|
||
...passwords.map((p, i) => el('span', {}, i ? ', ' : '', el('code', { text: p.password }), ` (${PASSWORD_SOURCES[p.source] ?? p.source})`))))
|
||
} else if (release.passworded) {
|
||
notices.push(el('div', { class: 'notice notice-bad', text: 'Passworded: its archive has encrypted entries or headers. The password is not in the index.' }))
|
||
}
|
||
if (!release.readable && !release.innerName) {
|
||
notices.push(el('div', { class: 'notice', text: release.inspectedAt
|
||
? 'Obfuscated file names, and its articles did not say more.'
|
||
: 'Obfuscated file names: what this release holds is only known once its articles are read (phase 2).' }))
|
||
}
|
||
|
||
const files = el('table', { class: 'file-table' },
|
||
el('tbody', {}, release.files.map((f) =>
|
||
el('tr', {},
|
||
el('td', { class: 'name' }, f.name),
|
||
el('td', { class: 'num', text: formatSize(f.size) }),
|
||
el('td', { class: 'num', text: `${f.parts} ${f.parts === 1 ? 'part' : 'parts'}` })))))
|
||
|
||
const inspections = release.inspections ?? []
|
||
const contents = inspections.length
|
||
? el('div', { class: 'contents' }, inspections.map((i) =>
|
||
el('div', { class: 'inspection' },
|
||
el('div', { class: 'inspection-head' },
|
||
el('span', { class: 'name', text: i.yencName || '(no name)' }),
|
||
badge(KIND_LABELS[i.kind] ?? i.kind),
|
||
i.type ? badge(i.type) : null,
|
||
i.passworded ? badge('Encrypted', 'bad') : null),
|
||
i.names?.length
|
||
? el('ul', { class: 'inspection-names' }, i.names.map((n) => el('li', { text: n })))
|
||
: null,
|
||
i.entries > (i.names?.length ?? 0)
|
||
? el('small', { text: `${number.format(i.entries)} in all; the largest shown` })
|
||
: null)))
|
||
: null
|
||
|
||
$('#detail').replaceChildren(
|
||
actions,
|
||
...notices,
|
||
facts,
|
||
...(contents ? section('Contents', `${inspections.length} ${inspections.length === 1 ? 'article' : 'articles'} read`, contents) : []),
|
||
...section('Files', `${release.files.length} posted`, files)
|
||
)
|
||
}
|
||
|
||
async function openRelease(id, push = false) {
|
||
state.release = id
|
||
writeUrl(push)
|
||
document.querySelectorAll('.release.selected').forEach((r) => r.classList.remove('selected'))
|
||
document.querySelector(`.release[data-id="${id}"]`)?.classList.add('selected')
|
||
|
||
$('#drawer').hidden = false
|
||
$('#scrim').hidden = false
|
||
$('#detail-name').textContent = ''
|
||
$('#detail-source').textContent = ''
|
||
$('#detail').replaceChildren(...Array.from({ length: 6 }, () => el('div', { class: 'skeleton' })))
|
||
$('#close').focus()
|
||
try {
|
||
renderDetail(await api(`/api/v1/releases/${id}`))
|
||
} catch (error) {
|
||
$('#detail').replaceChildren(el('p', { class: 'empty', text: `Could not load this release (${error.message}).` }))
|
||
}
|
||
}
|
||
|
||
function closeRelease(push = false) {
|
||
if (!state.release) return
|
||
const id = state.release
|
||
state.release = null
|
||
$('#drawer').hidden = true
|
||
$('#scrim').hidden = true
|
||
writeUrl(push)
|
||
document.querySelector(`.release[data-id="${id}"] .release-main`)?.focus()
|
||
}
|
||
|
||
// --- Wiring ----------------------------------------------------------------
|
||
|
||
$('#q').addEventListener('input', (event) => {
|
||
state.q = event.target.value.trim()
|
||
scheduleSearch()
|
||
})
|
||
for (const k of FIELDS) {
|
||
$(`#${k}`).addEventListener('input', (event) => {
|
||
state[k] = event.target.value.trim()
|
||
scheduleSearch()
|
||
})
|
||
}
|
||
$('#sort').addEventListener('change', (event) => {
|
||
state.sort = event.target.value
|
||
writeUrl()
|
||
loadResults()
|
||
})
|
||
for (const [parent, name, subs] of CATEGORIES) {
|
||
$('#cat').append(el('option', { value: String(parent), text: name }), ...subs.map(([id, sub]) => el('option', { value: String(id), text: `${name} > ${sub}` })))
|
||
}
|
||
$('#cat').addEventListener('change', (event) => {
|
||
state.cat = event.target.value
|
||
writeUrl()
|
||
loadResults()
|
||
})
|
||
$('#source').addEventListener('change', (event) => {
|
||
state.source = event.target.value
|
||
writeUrl()
|
||
loadResults()
|
||
})
|
||
for (const button of document.querySelectorAll('[data-filter]')) {
|
||
button.addEventListener('click', () => {
|
||
const f = button.dataset.filter
|
||
state[f] = !state[f]
|
||
button.setAttribute('aria-pressed', String(state[f]))
|
||
writeUrl()
|
||
loadResults()
|
||
})
|
||
}
|
||
$('#poster-clear').addEventListener('click', () => {
|
||
state.poster = ''
|
||
syncControls()
|
||
writeUrl()
|
||
loadResults()
|
||
})
|
||
$('#more').addEventListener('click', () => {
|
||
state.page++
|
||
loadResults({ append: true })
|
||
})
|
||
$('#close').addEventListener('click', () => closeRelease(true))
|
||
$('#scrim').addEventListener('click', () => closeRelease(true))
|
||
|
||
document.addEventListener('keydown', (event) => {
|
||
if (event.key === 'Escape') closeRelease(true)
|
||
if (event.key === '/' && document.activeElement !== $('#q')) {
|
||
event.preventDefault()
|
||
$('#q').focus()
|
||
$('#q').select()
|
||
}
|
||
})
|
||
|
||
window.addEventListener('popstate', () => {
|
||
const previous = { ...state }
|
||
readUrl()
|
||
syncControls()
|
||
const searchChanged = ['q', 'sort', 'source', 'cat', 'poster', ...FIELDS, ...FILTERS].some((k) => previous[k] !== state[k])
|
||
if (searchChanged) loadResults()
|
||
if (state.release && state.release !== previous.release) openRelease(state.release)
|
||
if (!state.release && previous.release) {
|
||
$('#drawer').hidden = true
|
||
$('#scrim').hidden = true
|
||
}
|
||
})
|
||
|
||
readUrl()
|
||
syncControls()
|
||
loadStats()
|
||
loadResults()
|
||
if (state.release) openRelease(state.release)
|