Feature: Paginated media listing As an API consumer, the RenewCMS media page or the editor picker I want GET /v1/media/ to answer one page of images with the total and the library-wide counts So that large libraries load quickly and every consumer pages the same way Background: Given GET /v1/media/ is mounted inside the jwtauth.Verifier + Authenticator group of api/router.go, unchanged And BREAKING CHANGE: the body is {"items": [...], "total": N, "page": P, "size": S, "counts": {"all": A, "unused": U, "duplicates": D}} in snake_case; next_cursor is removed and no X-Total-Count header is sent And each item is the existing media item (id, filename, url, size, content_type, width, height, sha256, uploaded_by, created_at, used_in, duplicate, variant_count, variant_size) and items is [] rather than null And paging follows the Activity and Articles convention: page is an integer >= 1 (default 1), size is one of 5, 25, 50, 100 (default 25) And q, hash, filter (all, unused, duplicates), sort (newest, oldest, name, largest, total_size) and variants (with, without) keep their current meaning and validation; the default order is newest And total is the number of images matching q, hash, filter and variants, before paging; page and size echo the values applied And counts stays library-wide: it ignores q, hash, filter, variants and paging And ordering is applied to the whole matching list before slicing, with id as the final tie-break, so pages never overlap or skip (newest and total_size are made deterministic; oldest, name and largest already tie on id) And a page beyond the last answers 200 with items [] and the real total And any invalid page, size, filter, sort, variants or hash answers 400 with the plain-text body "The media query is invalid." (apperr.MediaQueryErrorMessage); page and size are validated with validate tags through validator.Validate.Struct And cursor and limit are no longer parameters: they are ignored, never an error, and have no effect on paging And ListMediaUseCase reaches data only through domain interfaces; the domain ListQuery carries Page and Size instead of Cursor and Limit; the handler never touches a repository And the SPA sends explicit page and size on every list call Scenario: A bare request answers the envelope with the defaults Given the library holds 30 images When a verified client sends GET /v1/media/ Then the status is 200 And the body has page 1, size 25, total 30, 25 items and counts {"all": 30, "unused": U, "duplicates": D} And the body has no next_cursor key Scenario Outline: Defaults when only one parameter is given Given the library holds 60 images When a verified client sends GET /v1/media/? Then the body has page , size , total 60 and items Examples: | query | page | size | count | | page=2 | 2 | 25 | 25 | | page=3 | 3 | 25 | 10 | | size=5 | 1 | 5 | 5 | | size=50 | 1 | 50 | 50 | | size=100 | 1 | 100 | 60 | Scenario: Pages partition the list with no overlap Given the library holds 12 images When a client requests ?page=1&size=5, ?page=2&size=5 and ?page=3&size=5 Then the pages hold 5, 5 and 2 items, total is 12 on each, and no id repeats Scenario: Newest first with the id tie-break Given images id 1 uploaded 2026-09-01, id 2 and id 3 uploaded 2026-09-03 When a client sends GET /v1/media/?size=5 Then the item ids are 3, 2, 1 Scenario Outline: Sort orders the whole library before paging Given 12 images with distinct names, sizes and dates When a client requests ?sort=&size=5 for pages 1, 2 and 3 Then the pages hold the whole ordered list in with no repeat Examples: | sort | order | | newest | created_at descending | | oldest | created_at ascending | | name | filename stem, case-insensitive ascending | | largest | size descending | | total_size | original size plus generated variant bytes, descending | Scenario: Filter and variants narrow total and items but not the counts Given 40 images of which 6 are unused and 3 hold generated variants, and the library counts are {"all": 40, "unused": 6, "duplicates": 2} When a client sends GET /v1/media/?filter=unused&size=5 Then total is 6, the page holds 5 items and counts is {"all": 40, "unused": 6, "duplicates": 2} When a client sends GET /v1/media/?variants=with Then total is 3 and counts is unchanged Scenario: Search combines with paging Given 7 images whose filename contains "wall" and 20 others When a client sends GET /v1/media/?q=wall&size=5&page=2 Then total is 7, the page holds 2 items and counts.all is 27 Scenario: Hash lookup returns every copy in one page Given 3 images share the sha256 H When a client sends GET /v1/media/?hash=H&page=1&size=100 Then total is 3 and items has the 3 copies Scenario: Hash lookup with no paging uses the defaults Given 3 images share the sha256 H When a client sends GET /v1/media/?hash=H Then the body has page 1, size 25, total 3 and 3 items Scenario: A page beyond the last is empty with the real total Given the library holds 12 images When a client sends GET /v1/media/?page=99&size=5 Then the status is 200, items is [], total is 12, page is 99, size is 5 and counts.all is 12 Scenario: An empty library Given the library is empty When a client sends GET /v1/media/ Then the status is 200, items is [], total is 0 and counts is {"all": 0, "unused": 0, "duplicates": 0} Scenario: counts ignores every filter and the page Given the library holds 30 images When a client sends GET /v1/media/?q=zzzz&page=3&size=5 Then items is [], total is 0 and counts.all is 30 Scenario Outline: cursor and limit are ignored Given the library holds 30 images When a client sends GET /v1/media/? Then the status is 200 and the body has page 1, size 25 and 25 items Examples: | query | | cursor=abc | | limit=5 | | limit=0 | | limit=abc | | cursor=b2Zmc2V0OjU | Scenario Outline: Invalid parameters answer 400 When a client sends GET /v1/media/? Then the status is 400 And the body is "The media query is invalid." And the repository was not called Examples: | query | | page=0 | | page=-1 | | page=abc | | page= | | page=1.5 | | size=0 | | size=10 | | size=200 | | size=abc | | size= | | page=0&size=5 | | page=1&size=1000 | | filter=archived | | sort=biggest | | variants= | | variants=other | | hash=notahash | Scenario: Repository failure answers 500 Given the repository fails on its next call When a client sends GET /v1/media/ Then the status is 500 and the body is "Internal server error" Scenario: An unauthenticated caller is refused as before When a client sends GET /v1/media/?page=1&size=5 without a valid jwt cookie Then the response is the unchanged 401 of the auth group and the use case is not called