Files
Daan MeijerandClaude Opus 5.5 1322e42fb7
Test and deploy / test (push) Successful in 47s
Test and deploy / edge (push) Successful in 1m44s
Test and deploy / deploy (push) Skipped
Accounts with limits: a Newznab key and a tier per user, daily API and NZB counts, invites
ACCESS=accounts (config/access.ts; open stays the default and behaves as
before): Newznab and the catalog's releases routes name a user (apikey or
the web interface's token), whose tier sets searches and NZBs per UTC day
(Newznab 500/501, the catalog 429) and how far back they see (102/403).
Counted in one upsert per use, so the last one can't be taken twice;
caps, stats and refused requests aren't counted. Signup takes an invite
(SIGNUP), which may give a tier; ace access:invite, access:tier (extends a
running tier), access:suspend. The web interface signs in and shows the
day's usage and the Newznab key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:16:45 +02:00

682 lines
26 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.
// 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
/** Signing in (GET /api/v1/access): `open` needs none; with `accounts` the catalog takes a token. */
const account = {
mode: 'open',
signup: 'open',
token: storage('get', 'token'),
/** The profile's access: tier, limits, today's usage, the Newznab key. */
access: null,
}
/** localStorage, which a private window may refuse. */
function storage(action, key, value) {
try {
if (action === 'get') return localStorage.getItem(key)
if (action === 'set') localStorage.setItem(key, value)
else localStorage.removeItem(key)
} catch {
return 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, method = 'GET', body } = {}) {
const headers = { accept: 'application/json' }
if (account.token) headers.authorization = `Bearer ${account.token}`
if (body) headers['content-type'] = 'application/json'
const response = await fetch(path, { method, headers, signal, body: body && JSON.stringify(body) })
if (!response.ok) {
const error = await response.json().catch(() => ({}))
// A token that no longer works: sign in again.
if (response.status === 401 && account.mode === 'accounts' && !path.startsWith('/api/v1/auth/')) signOut()
throw new Error(error.error ?? error.errors?.[0]?.message ?? `${response.status} ${response.statusText}`)
}
return response.json()
}
/** A release's NZB as a plain link: with accounts it carries the key (a link can't send the token). */
function nzbHref(id) {
const key = account.mode === 'accounts' && account.access?.apiKey
return `/api/v1/releases/${id}/nzb${key ? `?apikey=${encodeURIComponent(key)}` : ''}`
}
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 = nzbHref(release.id)
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
// The day's count in the account button (the profile isn't counted).
if (account.access) loadProfile().catch(() => {})
} 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: nzbHref(release.id) },
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()
}
// --- Account (ACCESS=accounts) ---------------------------------------------
function renderAccountButton() {
const button = $('#account')
button.hidden = account.mode !== 'accounts'
if (!account.access) return button.replaceChildren('Sign in')
const { tier, usage, limits } = account.access
const of = (used, max) => (max === null ? number.format(used) : `${number.format(used)}/${number.format(max)}`)
button.title = 'Searches and NZBs today'
button.replaceChildren(el('span', { class: 'tier', text: tier }), ` ${of(usage.apiHits, limits.apiHits)} · ${of(usage.grabs, limits.grabs)} NZB`)
}
async function loadProfile() {
const { data } = await api('/api/v1/account/profile')
account.access = data.access
renderAccountButton()
}
/** Signed in with a new token: the profile, then the search that was waiting. */
async function signedIn(token) {
account.token = token
storage('set', 'token', token)
await loadProfile()
$('#account-dialog').close()
loadResults()
if (state.release) openRelease(state.release)
}
function signOut() {
if (account.token) api('/api/v1/account/logout', { method: 'POST' }).catch(() => {})
account.token = null
account.access = null
storage('remove', 'token')
renderAccountButton()
closeRelease()
$('#list').replaceChildren()
$('#more').hidden = true
$('#empty').hidden = true
$('#summary').textContent = 'Sign in to search the index.'
openAccount('signin')
}
function field(label, props) {
return el('label', {}, label, el('input', props))
}
function accountForm(view) {
const signup = view === 'signup'
const error = el('p', { class: 'form-error', role: 'alert', hidden: true })
const form = el(
'form',
{ class: 'form' },
field('Email', { name: 'email', type: 'email', autocomplete: 'email', required: true }),
field('Password', { name: 'password', type: 'password', autocomplete: signup ? 'new-password' : 'current-password', required: true, minlength: signup ? '8' : null }),
signup ? field('Password again', { name: 'passwordConfirmation', type: 'password', autocomplete: 'new-password', required: true }) : null,
signup && account.signup === 'invite' ? field('Invite code', { name: 'invite', autocomplete: 'off', spellcheck: 'false', required: true }) : null,
error,
el('button', { class: 'button button-primary', type: 'submit', text: signup ? 'Create account' : 'Sign in' })
)
form.addEventListener('submit', async (event) => {
event.preventDefault()
const values = Object.fromEntries(new FormData(form))
error.hidden = true
try {
const path = signup ? '/api/v1/auth/signup' : '/api/v1/auth/login'
const { data } = await api(path, { method: 'POST', body: signup ? { fullName: null, ...values } : values })
await signedIn(data.token)
} catch (failure) {
error.textContent = failure.message
error.hidden = false
}
})
const other = signup
? el('p', { class: 'form-switch' }, 'Have an account? ', el('button', { class: 'link-button', type: 'button', text: 'Sign in', onclick: () => openAccount('signin') }))
: account.signup !== 'closed'
? el('p', { class: 'form-switch' }, 'New here? ', el('button', { class: 'link-button', type: 'button', text: 'Create an account', onclick: () => openAccount('signup') }))
: null
return [form, other]
}
function meter(used, max) {
if (max === null) return null
const share = Math.min(1, used / Math.max(1, max))
return el('div', { class: `meter${used >= max ? ' meter-full' : ''}` }, el('span', { style: `width: ${Math.round(share * 100)}%` }))
}
function profileView() {
const { tier, tierUntil, limits, usage, apiKey } = account.access
const of = (used, max) => (max === null ? `${number.format(used)} (no limit)` : `${number.format(used)} of ${number.format(max)}`)
const key = el('code', { text: apiKey })
return [
el('dl', { class: 'facts' },
fact('Tier', tierUntil ? `${tier} until ${new Date(tierUntil).toLocaleDateString()}` : tier),
fact('Searches today', el('div', {}, of(usage.apiHits, limits.apiHits), meter(usage.apiHits, limits.apiHits))),
fact('NZBs today', el('div', {}, of(usage.grabs, limits.grabs), meter(usage.grabs, limits.grabs))),
fact('Retention', limits.retentionDays ? `${number.format(limits.retentionDays)} days` : 'Everything')),
...section('Newznab API key', 'for Prowlarr, Sonarr, Radarr', el('div', { class: 'api-key' },
key,
el('button', { class: 'button', type: 'button', text: 'Copy', onclick: async (event) => {
await navigator.clipboard?.writeText(account.access.apiKey)
event.target.textContent = 'Copied'
setTimeout(() => (event.target.textContent = 'Copy'), 1200)
} }),
el('button', { class: 'button', type: 'button', text: 'New key', title: 'The old key stops working', onclick: async () => {
if (!confirm('Make a new key? Apps using the old one stop working until they get the new one.')) return
const { apiKey: fresh } = await api('/api/v1/account/api-key', { method: 'POST' })
account.access.apiKey = fresh
key.textContent = fresh
} }))),
el('p', { class: 'form-switch' }, `URL for your apps: ${location.origin}`),
el('button', { class: 'button', type: 'button', text: 'Sign out', onclick: () => signOut() }),
]
}
async function openAccount(view) {
const dialog = $('#account-dialog')
if (view === 'profile') await loadProfile().catch(() => {})
if (view === 'profile' && !account.access) return
$('#account-title').textContent = { signin: 'Sign in', signup: 'Create an account', profile: 'Your account' }[view]
$('#account-body').replaceChildren(...(view === 'profile' ? profileView() : accountForm(view)))
if (!dialog.open) dialog.showModal()
dialog.querySelector('input')?.focus()
}
/** With accounts, nothing is searched before signing in. */
async function startAccess() {
try {
Object.assign(account, await api('/api/v1/access'))
} catch {
// An older server: open.
}
if (account.mode !== 'accounts') return true
if (account.token) await loadProfile().catch(() => {})
renderAccountButton()
if (account.access) return true
$('#summary').textContent = 'Sign in to search the index.'
if (!$('#account-dialog').open) openAccount('signin')
return false
}
// --- Wiring ----------------------------------------------------------------
$('#account').addEventListener('click', () => openAccount(account.access ? 'profile' : 'signin'))
$('#account-close').addEventListener('click', () => $('#account-dialog').close())
$('#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()
startAccess().then((ready) => {
if (!ready) return
loadResults()
if (state.release) openRelease(state.release)
})