Files
Daan MeijerandClaude Opus 5.5 e1392608d8
Test and deploy / test (push) Successful in 1m27s
Test and deploy / deploy (push) Successful in 2m9s
Search filters for size (MB) and file extensions
- API: min_size and max_size in MB, ext as a comma-separated list. An
  extension matches as a whole word in search_text (file and entry names,
  dots as spaces), so "iso" finds .iso files but not "ISOLATION", which a
  plain search word matched as a substring. The trigram index serves it.
- Web UI: a size range and an extension field in the filter row, kept in
  the URL like the other filters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:43:55 +02:00

560 lines
22 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Usenet Indexer web interface. No framework; all Usenet-derived text is
// rendered with textContent, never as HTML.
const $ = (selector) => document.querySelector(selector)
const FILTERS = ['readable', 'complete', 'hide_encrypted', 'par2_only']
/** Text inputs that narrow a search: size bounds in MB and file extensions. */
const FIELDS = ['min_size', 'max_size', 'ext']
const SOURCE_LABELS = {
stem: 'Filename',
subject: 'Subject',
post: 'Post id',
par2: 'par2 contents',
rar: 'RAR headers',
'weak-stem': 'Obfuscated filename',
nzb: 'Posted NZB',
spot: 'Spotnet',
}
const state = {
q: '',
sort: 'newest',
source: '',
poster: '',
min_size: '',
max_size: '',
ext: '',
readable: false,
complete: false,
hide_encrypted: false,
par2_only: 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.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.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
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.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 (CONCEPT.md D33).
const about = stats.approximate ? '≈ ' : ''
const items = [
['Releases', about + number.format(stats.releases)],
['Readable', `${about}${readable}%`],
['Files', about + number.format(stats.files)],
['Size', about + formatSize(stats.size)],
['Indexed', formatAge(stats.lastIndexedAt) || '–'],
]
$('#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(' · ')
const badges = row.querySelector('.badges')
if (release.par2Only) badges.append(badge('par2 only', 'warn', 'Only the par2 files arrived; the data they describe is missing'))
if (release.container === 'nzbe') badges.append(badge('Encrypted NZB', 'bad', 'An encrypted NZB container: unusable without its key'))
else if (release.container === 'rclone') badges.append(badge('Encrypted (rclone)', 'bad', 'rclone crypt: names and contents encrypted, unusable without the key'))
else if (release.container === 'yencbin') badges.append(badge('Encrypted (.bin)', 'bad', 'An encrypted .bin container (yEncBin): unusable without its key'))
else if (release.passwordState === 'missing') badges.append(badge('Password needed', 'bad', 'Password-protected archive; the password is not known'))
else if (release.passwordState === 'known') {
badges.append(badge('Password known', 'good', 'The password is in the NZB; SABnzbd and NZBGet use it to unpack'))
}
const files = `${release.fileCount} ${release.fileCount === 1 ? 'file' : 'files'}`
if (release.listingOnly) badges.append(badge('Spot', 'accent', 'A Spotnet spot: its NZB is fetched from Usenet when you download it'))
else 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 if (release.listedFiles) {
badges.append(badge(`${release.listedPresent}/${release.listedFiles} listed`, 'warn', 'Files the par2 set lists: present / total'))
} else badges.append(badge(files))
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]
}
/**
* Spotnet descriptions use UBB markup: [br], [b], [i], [u], [url=…]…[/url].
* Built as DOM nodes (never HTML); links only when they are http(s).
*/
function ubb(text) {
const root = el('div', { class: 'spot-description' })
const stack = [root]
const top = () => stack[stack.length - 1]
// Old (key 1) spots use HTML instead: line breaks and simple tags become UBB, the rest is dropped.
text = text
.replace(/<br\s*\/?>/gi, '[br]')
.replace(/<(\/?)(b|i|u)>/gi, '[$1$2]')
.replace(/<\/?[a-z][^>]*>/gi, '')
for (const token of text.split(/(\[\/?[a-z]+(?:=[^\]]*)?\])/i)) {
const tag = /^\[(\/?)([a-z]+)(?:=([^\]]*))?\]$/i.exec(token)
if (!tag) {
if (token) top().append(token)
continue
}
const [, closing, rawName, arg] = tag
const name = rawName.toLowerCase()
if (name === 'br') {
top().append(el('br'))
} else if (!closing && ['b', 'i', 'u', 'url'].includes(name)) {
const url = name === 'url' ? (arg ?? '').trim() : ''
const node = name === 'url'
? /^https?:\/\//i.test(url) ? el('a', { href: url, target: '_blank', rel: 'noopener noreferrer nofollow' }) : el('span')
: el({ b: 'strong', i: 'em', u: 'u' }[name])
node.dataset.ubb = name
top().append(node)
stack.push(node)
} else if (closing && stack.length > 1 && top().dataset.ubb === name) {
stack.pop()
}
// Other tags ([img], [color=…], …) are dropped.
}
return root
}
function spotSection(release) {
const spot = release.spot
if (!spot) return []
const image = spot.image?.url
? el('img', {
class: 'spot-image',
src: spot.image.url,
alt: '',
loading: 'lazy',
width: spot.image.width ?? undefined,
height: spot.image.height ?? undefined,
onerror: (event) => event.target.remove(),
})
: null
const categories = spot.categories?.head
? el('dl', { class: 'facts' },
fact('Category', spot.categories.head),
...spot.categories.subcats.map((sub) => fact(sub.type, sub.values.join(', '))))
: null
const links = el('div', { class: 'spot-links' },
spot.website && /^https?:\/\//i.test(spot.website)
? el('a', { href: spot.website, target: '_blank', rel: 'noopener noreferrer nofollow', text: spot.website })
: null,
spot.image?.external
? el('a', { href: spot.image.external, target: '_blank', rel: 'noopener noreferrer nofollow', text: 'Image (external site)' })
: null)
const note = [spot.poster && `by ${spot.poster}`, spot.tag && spot.tag !== spot.poster && `tag ${spot.tag}`].filter(Boolean).join(' · ')
return section('Spot', note, el('div', { class: 'spot' },
image,
el('div', { class: 'spot-body' },
categories,
spot.description ? ubb(spot.description) : el('p', { class: 'empty', text: 'No description.' }),
links.childElementCount ? links : null)))
}
function renderDetail(release) {
$('#detail-name').textContent = release.name
$('#detail-source').textContent = `Recognised by ${SOURCE_LABELS[release.source] ?? release.source ?? 'filename'}`
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 listedPresent = release.par2Files.filter((f) => f.present).length
const facts = el(
'dl',
{ class: 'facts' },
fact('Size', formatSize(release.size)),
fact('Files', release.fileTotal ? `${release.fileCount} of ${release.fileTotal}` : `${release.fileCount} posted`),
fact('par2 lists', release.par2Files.length ? `${listedPresent} of ${release.par2Files.length} present` : null),
fact('Posted', release.postedAt ? `${new Date(release.postedAt).toLocaleString()} (${formatAge(release.postedAt)})` : null),
fact('Poster', poster),
fact('Groups', release.groups?.join(', '))
)
const notices = []
if (release.container === 'nzbe') {
notices.push(el('div', { class: 'notice notice-bad', text: 'Encrypted NZB container ("nzbe"): an encrypted download index for others, unreadable without its key.' }))
} else if (release.container === 'yencbin') {
notices.push(el('div', { class: 'notice notice-bad', text: 'Encrypted .bin container (from the yEncBin uploader): the data is encrypted with a key that is not part of the post.' }))
} else if (release.container === 'rclone') {
notices.push(el('div', { class: 'notice notice-bad', text: 'rclone crypt: file names and contents are encrypted with a password and salt that are not part of the post.' }))
} else if (release.passwordState === 'missing') {
notices.push(el('div', { class: 'notice notice-bad', text: 'Password needed: the archive is password-protected, and neither the post nor its spot says the password.' }))
}
if (release.password) {
notices.push(el('div', { class: 'notice notice-good' },
el('div', {}, 'Password ', el('code', { class: 'password', text: release.password }),
release.passwordSource === 'spot' ? ' (from the spot\'s description)' : '',
` is included in the NZB; SABnzbd and NZBGet use it to unpack.${release.encrypted == null ? ' Whether the archive needs it is not known yet.' : ''}`)))
}
if (release.listingOnly) {
notices.push(el('div', { class: 'notice' },
`A Spotnet spot${release.spot?.poster ? ` by ${release.spot.poster}` : ''}. Its files are read from the spot's NZB on Usenet when you download it; that takes a few seconds the first time.`))
}
if (release.pending.binaries > 0) {
const pct = release.pending.partsTotal ? Math.round((100 * release.pending.partsHave) / release.pending.partsTotal) : 0
notices.push(
el('div', { class: 'notice notice-warn' },
el('div', { style: 'flex:1' },
`${release.pending.binaries} more ${release.pending.binaries === 1 ? 'file is' : 'files are'} still arriving (${pct}% of their parts seen).`,
el('div', { class: 'progress' }, el('span', { style: `width:${pct}%` }))))
)
}
const files = el('table', { class: 'file-table' },
el('tbody', {}, release.files.map((f) =>
el('tr', {},
el('td', { class: 'name' }, f.name,
f.realName && f.realName !== f.postedName ? el('span', { class: 'posted', text: `posted as ${f.postedName}` }) : null),
el('td', { class: 'num', text: formatSize(f.size) }),
el('td', { class: 'num', text: `${f.parts} ${f.parts === 1 ? 'part' : 'parts'}` })))))
const par2 = release.par2Files.length
? el('table', { class: 'file-table' },
el('tbody', {}, release.par2Files.map((f) =>
el('tr', {},
el('td', { class: `state ${f.present ? 'ok' : 'missing'}`, text: f.present ? '✓' : '·', title: f.present ? 'Present' : 'Not seen yet' }),
el('td', { class: 'name', text: f.name }),
el('td', { class: 'num', text: formatSize(f.size) })))))
: null
const contents = release.contents.length
? el('table', { class: 'file-table' },
el('tbody', {}, release.contents.map((entry) =>
el('tr', {},
el('td', { class: 'name', text: entry.name + (entry.directory ? '/' : '') }),
el('td', { class: 'num', text: entry.size === null ? '' : formatSize(entry.size) }),
el('td', { class: 'state', text: entry.encrypted ? '🔒' : '', title: entry.encrypted ? 'Encrypted' : undefined })))))
: null
$('#detail').replaceChildren(
actions,
...notices,
facts,
...spotSection(release),
...section('Files', `${release.files.length} posted`, files),
...(par2 ? section('par2 file list', `${listedPresent} of ${release.par2Files.length} present`, par2) : []),
...(contents ? section('Archive contents', `${release.contents.length} entries`, contents) : [])
)
}
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()
})
$('#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', '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)