diff --git a/app/controllers/releases_controller.ts b/app/controllers/releases_controller.ts index 8c7a347..d2fdc97 100644 --- a/app/controllers/releases_controller.ts +++ b/app/controllers/releases_controller.ts @@ -3,8 +3,14 @@ import SearchService, { type SortOrder } from '#services/SearchService' const flag = (value: unknown) => value === '1' || value === 'true' || value === true +/** A size in MB from the query string, as bytes. */ +function megabytes(value: unknown): number | undefined { + const mb = Number(value) + return value !== undefined && value !== '' && Number.isFinite(mb) && mb >= 0 ? Math.round(mb * 1024 * 1024) : undefined +} + export default class ReleasesController { - /** GET /api/v1/releases?q=&readable=1&complete=1&hide_encrypted=1&source=&poster=&sort=&page=&per_page= */ + /** GET /api/v1/releases?q=&readable=1&complete=1&hide_encrypted=1&min_size=&max_size=(MB)&ext=mkv,flac&source=&poster=&sort=&page=&per_page= */ async index({ request }: HttpContext) { const qs = request.qs() return SearchService.search({ @@ -15,6 +21,9 @@ export default class ReleasesController { includePar2Only: flag(qs.par2_only), source: typeof qs.source === 'string' && qs.source ? qs.source : undefined, poster: typeof qs.poster === 'string' && qs.poster ? qs.poster : undefined, + minSize: megabytes(qs.min_size), + maxSize: megabytes(qs.max_size), + extensions: typeof qs.ext === 'string' && qs.ext ? qs.ext.split(/[\s,]+/) : undefined, sort: qs.sort as SortOrder | undefined, page: Number(qs.page) || 1, perPage: Number(qs.per_page) || 50, diff --git a/app/services/SearchService.ts b/app/services/SearchService.ts index 776c198..11c26e7 100644 --- a/app/services/SearchService.ts +++ b/app/services/SearchService.ts @@ -45,6 +45,11 @@ export interface SearchParams { hideContainers?: boolean /** Include incomplete releases that consist of par2 files only (hidden by default). */ includePar2Only?: boolean + /** Size bounds in bytes (releases without a known size never match). */ + minSize?: number + maxSize?: number + /** File extensions (without the dot); a release matches if a file or archive entry has one of them. */ + extensions?: string[] /** Count matches up to this many past the page (default COUNT_LIMIT); `total` is capped there. */ countLimit?: number } @@ -112,6 +117,21 @@ class SearchService { bindings.push(`%${word.replace(/[\\%_]/g, (c) => `\\${c}`)}%`) } if (params.readable) where.push('is_readable_name(r.name)') + if (params.minSize !== undefined) { + where.push('r.size >= ?') + bindings.push(params.minSize) + } + if (params.maxSize !== undefined) { + where.push('r.size <= ?') + bindings.push(params.maxSize) + } + // search_text holds file and entry names with dots as spaces, so an + // extension is a whole word there ("iso" but not "isolation"). + const extensions = (params.extensions ?? []).map((e) => e.toLowerCase().replace(/^\./, '')).filter((e) => /^[a-z0-9]{1,8}$/.test(e)).slice(0, 8) + if (extensions.length > 0) { + where.push(`r.search_text ~ ?`) + bindings.push(`(^| )(${extensions.join('|')})( |$)`) + } if (params.hideEncrypted) where.push('(r.encrypted IS NOT TRUE OR r.password IS NOT NULL)') if (params.hideContainers) where.push('r.container IS NULL') // par2 files whose data never arrived (e.g. uploads spread over groups we can't fetch). diff --git a/public/app.css b/public/app.css index 01a2210..7156910 100644 --- a/public/app.css +++ b/public/app.css @@ -238,6 +238,43 @@ kbd { cursor: pointer; } +/* A chip holding inputs: size bounds and file extensions. */ +.field-chip { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 6px 0 13px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface); + font-size: 13px; + color: var(--muted); +} + +.field-chip:focus-within { + border-color: var(--accent); +} + +.field-chip input { + width: 64px; + height: 24px; + padding: 0 6px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface-2); + color: var(--text); + font: inherit; +} + +.field-chip input.ext { + width: 96px; +} + +.field-chip .unit { + padding-right: 7px; +} + .chip[aria-pressed='true'], .chip-active { border-color: var(--accent); diff --git a/public/app.js b/public/app.js index e864b4d..1784d5b 100644 --- a/public/app.js +++ b/public/app.js @@ -3,6 +3,8 @@ 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', @@ -19,6 +21,9 @@ const state = { sort: 'newest', source: '', poster: '', + min_size: '', + max_size: '', + ext: '', readable: false, complete: false, hide_encrypted: false, @@ -90,6 +95,7 @@ function readUrl() { 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 } @@ -100,6 +106,7 @@ function writeUrl(push = false) { 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}` : ''}` @@ -110,6 +117,7 @@ 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])) } @@ -130,6 +138,7 @@ function searchParams(page) { 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 } @@ -483,6 +492,12 @@ $('#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() @@ -528,7 +543,7 @@ window.addEventListener('popstate', () => { const previous = { ...state } readUrl() syncControls() - const searchChanged = ['q', 'sort', 'source', 'poster', ...FILTERS].some((k) => previous[k] !== state[k]) + 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) { diff --git a/public/index.html b/public/index.html index eccaba8..c0514ad 100644 --- a/public/index.html +++ b/public/index.html @@ -55,6 +55,17 @@ + + + + + + MB + + + + +