Appearance
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(theMediaManager"Pick from library" panel), and - the
/admin/medialibrary screen loader.
Query parameters
All optional; unknown/blank values degrade to "no filter" rather than erroring.
| Param | Values | Effect |
|---|---|---|
q | free text | Case-insensitive LIKE over filename, alt_text, title. Wildcards (%, _) in the input are escaped and matched literally. |
source | manual | drupal | The App / Drupal split. manual = admin-uploaded; drupal = synced. |
type | image | video | Media kind. |
usage | used | unused | EXISTS / NOT EXISTS against media_links. unused subsumes the old orphan view (groom decision 5). |
tagType + tagRef | e.g. system + a system id | Tag facet (#377) via EXISTS on media_tags. Both parts are required; a lone half is ignored. |
coverage | untagged | ai-unreviewed | ungrouped | grouped | Coverage-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"). |
sort | newest (default) | oldest | name-asc | name-desc | gallery | Result 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. |
limit | 1–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-directionidtiebreak so pages don't overlap or skip:newest(default) →created_at DESC, id DESC(preserves the pre-#473 order).oldest→created_at ASC, id ASC.name-asc/name-desc→lower(filename) ASC|DESC, id ASC|DESC(case-insensitive).gallery(#1241) → parent-projectgallery_order→ per-imagegallery_position→created_at→id, via index-backed correlated subqueries. The/admin/mediaworkbench 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 pageLIMIT). Amedia_assets(lower(filename))expression index is the future optimization if the library grows very large; the recency sorts already ride thecreated_atindex (migration0029). - Bounded work: only the page's rows and their link counts are materialized. Link counts come from a single
GROUP BYover just the page's ids (WHERE media_asset_id IN (…page ids…)), never the wholemedia_linkstable. - 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)— theusageEXISTSand per-page counts.media_tags(tag_type, tag_ref)— the tag facetEXISTS.
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.
Related
- Managing media — the admin how-to.
- Media tagging — the
(tag_type, tag_ref)facet this query filters on. - Referenced media & the display-source contract — #384: App/Drupal tabs, display from
images.nanawall.com, and on-demand import, all building on thesourcefilter defined here.