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>
This commit is contained in:
1 parent
95f1d427df
commit
e1392608d8
6 files changed
+110
-2
No files matched your search
@@ -3,8 +3,14 @@ import SearchService, { type SortOrder } from '#services/SearchService'
|
|||||||
|
|
||||||
const flag = (value: unknown) => value === '1' || value === 'true' || value === true
|
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 {
|
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) {
|
async index({ request }: HttpContext) {
|
||||||
const qs = request.qs()
|
const qs = request.qs()
|
||||||
return SearchService.search({
|
return SearchService.search({
|
||||||
@@ -15,6 +21,9 @@ export default class ReleasesController {
|
|||||||
includePar2Only: flag(qs.par2_only),
|
includePar2Only: flag(qs.par2_only),
|
||||||
source: typeof qs.source === 'string' && qs.source ? qs.source : undefined,
|
source: typeof qs.source === 'string' && qs.source ? qs.source : undefined,
|
||||||
poster: typeof qs.poster === 'string' && qs.poster ? qs.poster : 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,
|
sort: qs.sort as SortOrder | undefined,
|
||||||
page: Number(qs.page) || 1,
|
page: Number(qs.page) || 1,
|
||||||
perPage: Number(qs.per_page) || 50,
|
perPage: Number(qs.per_page) || 50,
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ export interface SearchParams {
|
|||||||
hideContainers?: boolean
|
hideContainers?: boolean
|
||||||
/** Include incomplete releases that consist of par2 files only (hidden by default). */
|
/** Include incomplete releases that consist of par2 files only (hidden by default). */
|
||||||
includePar2Only?: boolean
|
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. */
|
/** Count matches up to this many past the page (default COUNT_LIMIT); `total` is capped there. */
|
||||||
countLimit?: number
|
countLimit?: number
|
||||||
}
|
}
|
||||||
@@ -112,6 +117,21 @@ class SearchService {
|
|||||||
bindings.push(`%${word.replace(/[\\%_]/g, (c) => `\\${c}`)}%`)
|
bindings.push(`%${word.replace(/[\\%_]/g, (c) => `\\${c}`)}%`)
|
||||||
}
|
}
|
||||||
if (params.readable) where.push('is_readable_name(r.name)')
|
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.hideEncrypted) where.push('(r.encrypted IS NOT TRUE OR r.password IS NOT NULL)')
|
||||||
if (params.hideContainers) where.push('r.container IS 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).
|
// par2 files whose data never arrived (e.g. uploads spread over groups we can't fetch).
|
||||||
|
|||||||
@@ -238,6 +238,43 @@ kbd {
|
|||||||
cursor: pointer;
|
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[aria-pressed='true'],
|
||||||
.chip-active {
|
.chip-active {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
|
|||||||
+16
-1
@@ -3,6 +3,8 @@
|
|||||||
|
|
||||||
const $ = (selector) => document.querySelector(selector)
|
const $ = (selector) => document.querySelector(selector)
|
||||||
const FILTERS = ['readable', 'complete', 'hide_encrypted', 'par2_only']
|
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 = {
|
const SOURCE_LABELS = {
|
||||||
stem: 'Filename',
|
stem: 'Filename',
|
||||||
subject: 'Subject',
|
subject: 'Subject',
|
||||||
@@ -19,6 +21,9 @@ const state = {
|
|||||||
sort: 'newest',
|
sort: 'newest',
|
||||||
source: '',
|
source: '',
|
||||||
poster: '',
|
poster: '',
|
||||||
|
min_size: '',
|
||||||
|
max_size: '',
|
||||||
|
ext: '',
|
||||||
readable: false,
|
readable: false,
|
||||||
complete: false,
|
complete: false,
|
||||||
hide_encrypted: false,
|
hide_encrypted: false,
|
||||||
@@ -90,6 +95,7 @@ function readUrl() {
|
|||||||
state.sort = params.get('sort') ?? 'newest'
|
state.sort = params.get('sort') ?? 'newest'
|
||||||
state.source = params.get('source') ?? ''
|
state.source = params.get('source') ?? ''
|
||||||
state.poster = params.get('poster') ?? ''
|
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'
|
for (const f of FILTERS) state[f] = params.get(f) === '1'
|
||||||
state.release = params.get('release') ? Number(params.get('release')) : null
|
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.sort !== 'newest') params.set('sort', state.sort)
|
||||||
if (state.source) params.set('source', state.source)
|
if (state.source) params.set('source', state.source)
|
||||||
if (state.poster) params.set('poster', state.poster)
|
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')
|
for (const f of FILTERS) if (state[f]) params.set(f, '1')
|
||||||
if (state.release) params.set('release', String(state.release))
|
if (state.release) params.set('release', String(state.release))
|
||||||
const url = `${location.pathname}${params.size ? `?${params}` : ''}`
|
const url = `${location.pathname}${params.size ? `?${params}` : ''}`
|
||||||
@@ -110,6 +117,7 @@ function syncControls() {
|
|||||||
$('#q').value = state.q
|
$('#q').value = state.q
|
||||||
$('#sort').value = state.sort
|
$('#sort').value = state.sort
|
||||||
$('#source').value = state.source
|
$('#source').value = state.source
|
||||||
|
for (const k of FIELDS) $(`#${k}`).value = state[k]
|
||||||
for (const f of FILTERS) {
|
for (const f of FILTERS) {
|
||||||
document.querySelector(`[data-filter="${f}"]`).setAttribute('aria-pressed', String(state[f]))
|
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.q) params.set('q', state.q)
|
||||||
if (state.source) params.set('source', state.source)
|
if (state.source) params.set('source', state.source)
|
||||||
if (state.poster) params.set('poster', state.poster)
|
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')
|
for (const f of FILTERS) if (state[f]) params.set(f, '1')
|
||||||
return params
|
return params
|
||||||
}
|
}
|
||||||
@@ -483,6 +492,12 @@ $('#q').addEventListener('input', (event) => {
|
|||||||
state.q = event.target.value.trim()
|
state.q = event.target.value.trim()
|
||||||
scheduleSearch()
|
scheduleSearch()
|
||||||
})
|
})
|
||||||
|
for (const k of FIELDS) {
|
||||||
|
$(`#${k}`).addEventListener('input', (event) => {
|
||||||
|
state[k] = event.target.value.trim()
|
||||||
|
scheduleSearch()
|
||||||
|
})
|
||||||
|
}
|
||||||
$('#sort').addEventListener('change', (event) => {
|
$('#sort').addEventListener('change', (event) => {
|
||||||
state.sort = event.target.value
|
state.sort = event.target.value
|
||||||
writeUrl()
|
writeUrl()
|
||||||
@@ -528,7 +543,7 @@ window.addEventListener('popstate', () => {
|
|||||||
const previous = { ...state }
|
const previous = { ...state }
|
||||||
readUrl()
|
readUrl()
|
||||||
syncControls()
|
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 (searchChanged) loadResults()
|
||||||
if (state.release && state.release !== previous.release) openRelease(state.release)
|
if (state.release && state.release !== previous.release) openRelease(state.release)
|
||||||
if (!state.release && previous.release) {
|
if (!state.release && previous.release) {
|
||||||
|
|||||||
@@ -55,6 +55,17 @@
|
|||||||
<option value="spot">Spotnet</option>
|
<option value="spot">Spotnet</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<span class="field-chip" title="Release size in MB">
|
||||||
|
<label for="min_size">Size</label>
|
||||||
|
<input type="number" id="min_size" min="0" step="any" inputmode="decimal" placeholder="min" aria-label="Minimum size in MB" />
|
||||||
|
<span aria-hidden="true">–</span>
|
||||||
|
<input type="number" id="max_size" min="0" step="any" inputmode="decimal" placeholder="max" aria-label="Maximum size in MB" />
|
||||||
|
<span class="unit">MB</span>
|
||||||
|
</span>
|
||||||
|
<span class="field-chip" title="Releases with a file or archive entry of these types, e.g. mkv, flac">
|
||||||
|
<label for="ext">Ext</label>
|
||||||
|
<input type="text" id="ext" class="ext" placeholder="mkv, flac" autocomplete="off" spellcheck="false" />
|
||||||
|
</span>
|
||||||
<span class="chip chip-active" id="poster-filter" hidden>
|
<span class="chip chip-active" id="poster-filter" hidden>
|
||||||
<span id="poster-filter-label"></span>
|
<span id="poster-filter-label"></span>
|
||||||
<button type="button" id="poster-clear" aria-label="Remove poster filter">×</button>
|
<button type="button" id="poster-clear" aria-label="Remove poster filter">×</button>
|
||||||
|
|||||||
@@ -47,6 +47,22 @@ test.group('Web API', (group) => {
|
|||||||
;(await client.get('/api/v1/releases/999999999')).assertStatus(404)
|
;(await client.get('/api/v1/releases/999999999')).assertStatus(404)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('filters by size in MB and by file extension as a whole word', async ({ client, assert }) => {
|
||||||
|
const id = await seed() // 1400 bytes, an episode.mkv in its listing
|
||||||
|
const ids = async (qs: Record<string, string>) =>
|
||||||
|
(await client.get('/api/v1/releases').qs({ q: 'webtest', ...qs })).body().releases.map((r: any) => r.id)
|
||||||
|
|
||||||
|
assert.include(await ids({ min_size: '0.001', max_size: '0.002' }), id)
|
||||||
|
assert.notInclude(await ids({ min_size: '1' }), id)
|
||||||
|
assert.notInclude(await ids({ max_size: '0.001' }), id)
|
||||||
|
|
||||||
|
assert.include(await ids({ ext: 'mkv' }), id)
|
||||||
|
assert.include(await ids({ ext: '.iso, MKV' }), id)
|
||||||
|
assert.notInclude(await ids({ ext: 'iso' }), id)
|
||||||
|
// "sho" is part of "show", not an extension.
|
||||||
|
assert.notInclude(await ids({ ext: 'sho' }), id)
|
||||||
|
})
|
||||||
|
|
||||||
test('serves an NZB with the real file name', async ({ client, assert }) => {
|
test('serves an NZB with the real file name', async ({ client, assert }) => {
|
||||||
const id = await seed()
|
const id = await seed()
|
||||||
const response = await client.get(`/api/v1/releases/${id}/nzb`)
|
const response = await client.get(`/api/v1/releases/${id}/nzb`)
|
||||||
|
|||||||
Reference in new issue
Block a user