- Search matches a word in the name or the inner name (trigram index on inner_name, partial, built concurrently); "Readable names" includes releases with an inner name. - unlocked=1 (chip "No password") leaves out passworded releases. - Results carry passworded and innerName: a Password badge, the inner name under the release name. - The release view: Inside and Inspected facts, a passworded notice, and Contents (per probed article its yEnc name, kind, type, encryption and the names inside) from release_inspections. - Newznab: the password attribute from the prober; an obfuscated release with an inner name is included, titled by it (without its extension). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
500 lines
18 KiB
JavaScript
500 lines
18 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',
|
||
}
|
||
/** 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) 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.' }))
|
||
}
|
||
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)
|