Skip to content

Media library query contract

Server-side search / filter / pagination over the media library (#375, part of the media-management capability #376). The Drupal sync (#365) can bring tens of thousands of assets into media_assets. Loading the whole table and filtering in JavaScript does not scale — so both library surfaces run their query in SQL and return one page at a time.

The contract

The single source of truth is searchLibrary in src/lib/server/admin/media.ts, fed by the pure parseLibraryQuery param normalizer. Both admin surfaces call them:

  • the picker endpoint GET /admin/media/library (the MediaManager "Pick from library" panel), and
  • the /admin/media library screen loader.

Query parameters

All optional; unknown/blank values degrade to "no filter" rather than erroring.

ParamValuesEffect
qfree textCase-insensitive LIKE over filename, alt_text, title. Wildcards (%, _) in the input are escaped and matched literally.
sourcemanual | drupalThe App / Drupal split. manual = admin-uploaded; drupal = synced.
typeimage | videoMedia kind.
usageused | unusedEXISTS / NOT EXISTS against media_links. unused subsumes the old orphan view (groom decision 5).
tagType + tagRefe.g. system + a system idTag facet (#377) via EXISTS on media_tags. Both parts are required; a lone half is ignored.
coverageuntagged | ai-unreviewed | ungrouped | groupedCoverage-state queues (#1371): untagged = no live classification (no media_tags row — the single live store since #1805; the project axis excluded so it composes with project scope); ai-unreviewed = carries proposed visual tags (the review backlog); ungrouped/grouped = shot-group membership. Indexed EXISTS probes — they hold across pagination and compose with every other filter ("untagged images in project X, gallery order").
sortnewest (default) | oldest | name-asc | name-desc | galleryResult order (#473). A whitelisted enum mapped to fixed ORDER BY columns — never raw input; unknown/blank degrades to newest. gallery (#1241) is nanawall.com display order — parent-project gallery_order, then per-image gallery position; assets with no project/gallery data sort last (NULLs last). Exposed in the LibraryBrowser sort select (#1483) on every surface that renders the filter bar; /admin/media's no-param default is gallery (#1241 AC-03), so there the param appears only for a non-gallery choice, while pickers keep the newest default. See Ordering & bounds.
limit1–100 (default 24)Page size, clamped. A caller cannot request the whole table.
offset≥ 0 (default 0)Pagination offset.

Back-compat: the pre-#375 ?unused=1 (picker) and ?filter=unused (screen) links are still honoured as aliases for usage=unused.

Response

searchLibrary returns { items, total }, where items is one page of LibraryItem (client-safe view model + linkCount, filename, createdAt, source) in the requested sort order (newest-first by default), and total is the count of all matches for the same filters (for the pager). The endpoint wraps this as { assets, total, limit, offset }.

Ordering & bounds

  • Order (sort, #473) — a whitelisted enum mapped to fixed ORDER BY columns (orderByForSort), never raw input. Every order carries a same-direction id tiebreak so pages don't overlap or skip:
    • newest (default) → created_at DESC, id DESC (preserves the pre-#473 order).
    • oldestcreated_at ASC, id ASC.
    • name-asc / name-desclower(filename) ASC|DESC, id ASC|DESC (case-insensitive).
    • gallery (#1241) → parent-project gallery_order → per-image gallery_positioncreated_atid, via index-backed correlated subqueries. The /admin/media workbench defaults to this; see the gallery-order reference.

Projects autocomplete — GET /admin/media/projects

The media gallery's project navigator (#1242) resolves a project by name fast, then scopes the library to it. GET /admin/media/projects?q=<fragment>&limit=<n≤50, default 12> returns { projects: [{ uuid, name, slug, galleryOrder, imageCount }] } ordered by gallery_order (NULLs last) then name — projectGalleryNavigator (edit_data guarded). It is bounded (capped limit, indexed name LIKE), never a full-list load. Selecting a suggestion navigates to ?tagType=project&tagRef=<uuid>, which reuses the project tag facet + the gallery sort here, so the scoped images render in Drupal /projects/foo order.

  • Name-sort cost: lower(filename) ordering is a bounded sort — same posture as the free-text search below (bounded by the page LIMIT). A media_assets(lower(filename)) expression index is the future optimization if the library grows very large; the recency sorts already ride the created_at index (migration 0029).
  • Bounded work: only the page's rows and their link counts are materialized. Link counts come from a single GROUP BY over just the page's ids (WHERE media_asset_id IN (…page ids…)), never the whole media_links table.
  • Indexes (migration 0029) keep the common path cheap at Drupal scale:
    • media_assets(created_at) — the pagination sort.
    • media_assets(source), media_assets(type) — the facet filters.
    • media_links(media_asset_id) — the usage EXISTS and per-page counts.
    • media_tags(tag_type, tag_ref) — the tag facet EXISTS.

Known limitation: text search is a bounded scan

q uses a leading-wildcard LIKE %term%, which cannot use an index — SQLite scans rows until the page LIMIT is filled. This is intentional for v1: the page limit bounds the work, and text search is the least-common path (facets + tags are the primary way to narrow a large library). If profiling shows it matters at full Drupal scale, the upgrade path is FTS5 over filename/alt_text/title — a change local to searchLibrary, invisible to callers.