diff --git a/Makefile b/Makefile index 044d4acabb..77eec6e733 100644 --- a/Makefile +++ b/Makefile @@ -66,8 +66,7 @@ lint-changed: # an entry along with its fix, and never extend it to land a change. The Go # suite has no equivalent — a Go test that cannot pass yet carries a t.Skip and # its reason in the source, where whoever reads the test finds it. -WEBTEST_KNOWN_FAILURES := \ - --exclude src/pages/Catalog.test.tsx +WEBTEST_KNOWN_FAILURES := # The Go binary embeds the built frontend, so every Go build and test needs # web/dist to exist. Tests never serve it, so a placeholder is enough; `make diff --git a/cmd/silo/main.go b/cmd/silo/main.go index 13799f27ea..df828fd95d 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -2786,8 +2786,23 @@ func main() { deps.TrendingRefresher = trendingRefresher if deps.UserStoreProvider != nil { - userSync := usercollections.NewService(deps.UserStoreProvider, collItemRepo, libraryItemRepo, nil, slog.Default()) + // Imports fill their item limit with titles their owner profile + // can access. Scheduled syncs start with the task manager below, + // so the owner resolver is wired here, not in the router. + var ownerScopes scopeResolver + if policySystem != nil { + ownerScopes = policy.NewViewerResolver(auth.NewUserRepository(deps.DB), deps.UserStoreProvider, nil, policySystem.PDP(), accessGroupStore).WithUnratedContentPolicy(unratedContent) + } else { + // Legacy resolver: proxy/test wiring without a policy system. Production integrated/api modes always take the policy path. Removed with the legacy cleanup phase. + ownerScopes = access.NewResolver(auth.NewUserRepository(deps.DB), deps.UserStoreProvider, nil, accessGroupStore).WithUnratedContentPolicy(unratedContent) + } + userSync := usercollections.NewService(deps.UserStoreProvider, collItemRepo, libraryItemRepo, usercollections.NewOwnerAccess(ownerScopes), nil, slog.Default()) userSync.TMDBCollections = collectionService.TMDBCollections + // Syncs refresh collages and start with the task manager below, + // before the router exists, so the collage service is shared from + // here; the router gives it its generator. + deps.PersonalCollectionCollages = catalog.NewPersonalCollectionCollages(deps.DB, nil) + userSync.Collages = deps.PersonalCollectionCollages // Trakt fetchers are wired in router.go (they need settingsRepo); // router.go propagates them onto userSync once configured. userCollectionScheduler = usercollections.NewScheduler(deps.DB, userSync, slog.Default()) diff --git a/contracts/api/v2/breaking-approvals.json b/contracts/api/v2/breaking-approvals.json index 08ac789b35..8af8d93aef 100644 --- a/contracts/api/v2/breaking-approvals.json +++ b/contracts/api/v2/breaking-approvals.json @@ -1,3 +1,95 @@ { - "approvals": [] + "approvals": [ + { + "operation_id": "createCollection", + "change_id": "request-property-removed", + "fingerprint": "4335d9d8cd09082a7556eea7d88a42e7a7b1ec90397226c3e0eb6a5e8ccbc7e4", + "reason": "Pre-lock removal for #1615: personal collection sharing is one switch (is_shared shows a collection to every profile on the login), so the per-profile allow list is no longer accepted. The web client stops sending it in the same change; silo-apple never sends it and silo-android only declares it with a null default that its JSON configuration omits (follow-ups silo-apple#534, silo-android#406).", + "approved_in": "#1615" + }, + { + "operation_id": "createCollection", + "change_id": "response-required-property-removed", + "fingerprint": "3527c887f3821ec21b6e91fcc4921192a600b72362d1cee5e58d6da6609eb7a7", + "reason": "Pre-lock removal for #1615: personal collection sharing is one switch (is_shared shows a collection to every profile on the login), so the per-profile allow list is no longer reported. The web client stops reading it in the same change; silo-apple does not decode it and silo-android defaults it to an empty list (follow-ups silo-apple#534, silo-android#406).", + "approved_in": "#1615" + }, + { + "operation_id": "getCollection", + "change_id": "response-required-property-removed", + "fingerprint": "1615cc847ecf3620a4adb10ed9a31d3025a2f58b7fe938e713342513fb2dc99c", + "reason": "Pre-lock removal for #1615: personal collection sharing is one switch (is_shared shows a collection to every profile on the login), so the per-profile allow list is no longer reported. The web client stops reading it in the same change; silo-apple does not decode it and silo-android defaults it to an empty list (follow-ups silo-apple#534, silo-android#406).", + "approved_in": "#1615" + }, + { + "operation_id": "importMDBListCollection", + "change_id": "response-required-property-removed", + "fingerprint": "7c2166381b851377739abd919a33b6f0d4915a8f4b9e0d4dc0d61f4fc01d3d19", + "reason": "Pre-lock removal for #1615: personal collection sharing is one switch (is_shared shows a collection to every profile on the login), so the per-profile allow list is no longer reported. The web client stops reading it in the same change; silo-apple does not decode it and silo-android defaults it to an empty list (follow-ups silo-apple#534, silo-android#406).", + "approved_in": "#1615" + }, + { + "operation_id": "importTMDBCollection", + "change_id": "response-required-property-removed", + "fingerprint": "eb9653c04f0ef88685667b075ab1939dbb8bf5a39398612aeb949f34dbd6a9e6", + "reason": "Pre-lock removal for #1615: personal collection sharing is one switch (is_shared shows a collection to every profile on the login), so the per-profile allow list is no longer reported. The web client stops reading it in the same change; silo-apple does not decode it and silo-android defaults it to an empty list (follow-ups silo-apple#534, silo-android#406).", + "approved_in": "#1615" + }, + { + "operation_id": "importTMDBListCollection", + "change_id": "response-required-property-removed", + "fingerprint": "602971b18d7e48319e2afae8557893940d9e03c1b883066b15a8a1e7f2ac0e2f", + "reason": "Pre-lock removal for #1615: personal collection sharing is one switch (is_shared shows a collection to every profile on the login), so the per-profile allow list is no longer reported. The web client stops reading it in the same change; silo-apple does not decode it and silo-android defaults it to an empty list (follow-ups silo-apple#534, silo-android#406).", + "approved_in": "#1615" + }, + { + "operation_id": "importTraktCollection", + "change_id": "response-required-property-removed", + "fingerprint": "9aa5634209e0dc004f47d2876cfd1e9cd96d53bb351205e32e5559eb99ad8cd2", + "reason": "Pre-lock removal for #1615: personal collection sharing is one switch (is_shared shows a collection to every profile on the login), so the per-profile allow list is no longer reported. The web client stops reading it in the same change; silo-apple does not decode it and silo-android defaults it to an empty list (follow-ups silo-apple#534, silo-android#406).", + "approved_in": "#1615" + }, + { + "operation_id": "listCollections", + "change_id": "response-required-property-removed", + "fingerprint": "faaa77f5e6c57ca17a6218625fbc09278b33af9c4eeb2380295be18d69ecf5a1", + "reason": "Pre-lock removal for #1615: personal collection sharing is one switch (is_shared shows a collection to every profile on the login), so the per-profile allow list is no longer reported. The web client stops reading it in the same change; silo-apple does not decode it and silo-android defaults it to an empty list (follow-ups silo-apple#534, silo-android#406).", + "approved_in": "#1615" + }, + { + "operation_id": "updateCollection", + "change_id": "request-property-removed", + "fingerprint": "039bb432c950e06d7c4fc0aea5f767808015a81e44b632e06c1ca75b69f37751", + "reason": "Pre-lock removal for #1615: personal collection sharing is one switch (is_shared shows a collection to every profile on the login), so the per-profile allow list is no longer accepted. The web client stops sending it in the same change; silo-apple never sends it and silo-android only declares it with a null default that its JSON configuration omits (follow-ups silo-apple#534, silo-android#406).", + "approved_in": "#1615" + }, + { + "operation_id": "updateCollection", + "change_id": "response-required-property-removed", + "fingerprint": "827e1959e5752120f5e54debb3e05a0f346884a1afd64730889d68bc221a50c9", + "reason": "Pre-lock removal for #1615: personal collection sharing is one switch (is_shared shows a collection to every profile on the login), so the per-profile allow list is no longer reported. The web client stops reading it in the same change; silo-apple does not decode it and silo-android defaults it to an empty list (follow-ups silo-apple#534, silo-android#406).", + "approved_in": "#1615" + }, + { + "operation_id": "uploadCollectionPoster", + "change_id": "response-required-property-removed", + "fingerprint": "074bdfeef759bb891ab58ff4b3ca50bcd310dfb42d5cbb675d2bc12449e318ca", + "reason": "Pre-lock removal for #1615: personal collection sharing is one switch (is_shared shows a collection to every profile on the login), so the per-profile allow list is no longer reported. The web client stops reading it in the same change; silo-apple does not decode it and silo-android defaults it to an empty list (follow-ups silo-apple#534, silo-android#406).", + "approved_in": "#1615" + }, + { + "operation_id": "getAdminSectionSettings", + "change_id": "api-path-removed-without-deprecation", + "fingerprint": "c84f2053ffc9feaf140d1efe483b1a890e53caffbae740a5088c76804635638e", + "reason": "Pre-lock removal, owner decision: the Let profiles add rule rows setting is gone and profiles may always add rule rows, so there is nothing left to read. getProfileSectionFlags keeps allow_profile_custom_sections, always true and deprecated. The web admin stops calling this operation in the same change; silo-apple and silo-android never called it. The frozen /api/v1 route keeps answering, always true.", + "approved_in": "#1991" + }, + { + "operation_id": "updateAdminSectionSettings", + "change_id": "api-path-removed-without-deprecation", + "fingerprint": "53d640609cee613e839b55131e2dd2d5ca929c38a80b33c685a35c44a5e8a767", + "reason": "Pre-lock removal, owner decision: the Let profiles add rule rows setting is gone and profiles may always add rule rows, so there is nothing left to change. The web admin's More menu switch, the only caller, is removed in the same change; silo-apple and silo-android never called it. The frozen /api/v1 route still accepts a write and changes nothing.", + "approved_in": "#1991" + } + ] } diff --git a/contracts/api/v2/fixtures/admin_account_profile_section_settings.json b/contracts/api/v2/fixtures/admin_account_profile_section_settings.json index cc2b63b708..07132bd516 100644 --- a/contracts/api/v2/fixtures/admin_account_profile_section_settings.json +++ b/contracts/api/v2/fixtures/admin_account_profile_section_settings.json @@ -4,6 +4,7 @@ "id": "s-continue", "section_type": "continue_watching", "title": "Continue Watching", + "default_title": "Continue Watching", "featured": false, "item_limit": 20, "hidden": true, @@ -15,6 +16,7 @@ "id": "u-gems", "section_type": "hidden_gems", "title": "Hidden gems", + "default_title": "", "featured": false, "item_limit": 12, "hidden": false, diff --git a/contracts/api/v2/fixtures/admin_login_session_forbidden.json b/contracts/api/v2/fixtures/admin_login_session_forbidden.json index 7297bb7cb5..7e95ea3ac7 100644 --- a/contracts/api/v2/fixtures/admin_login_session_forbidden.json +++ b/contracts/api/v2/fixtures/admin_login_session_forbidden.json @@ -3,5 +3,5 @@ "title": "Permission denied", "status": 403, "detail": "Admin access required", - "instance": "urn:silo:request:0000000000000000000002dd" + "instance": "urn:silo:request:0000000000000000000002e6" } diff --git a/contracts/api/v2/fixtures/collection_group_stale.json b/contracts/api/v2/fixtures/collection_group_stale.json deleted file mode 100644 index 01a0ce446e..0000000000 --- a/contracts/api/v2/fixtures/collection_group_stale.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "type": "https://siloserver.org/docs/api/v2/problems/precondition_failed", - "title": "Precondition failed", - "status": 412, - "detail": "The resource changed while the request was being applied; fetch it again and retry against the current ETag.", - "instance": "urn:silo:request:00000000000000000000010c" -} diff --git a/contracts/api/v2/fixtures/collection_group_unsupported.json b/contracts/api/v2/fixtures/collection_group_unsupported.json new file mode 100644 index 0000000000..64115ea8d6 --- /dev/null +++ b/contracts/api/v2/fixtures/collection_group_unsupported.json @@ -0,0 +1,7 @@ +{ + "type": "https://siloserver.org/docs/api/v2/problems/capability_unsupported", + "title": "Capability unsupported", + "status": 501, + "detail": "The acting account does not support collection groups", + "instance": "urn:silo:request:00000000000000000000010c" +} diff --git a/contracts/api/v2/fixtures/collection_order_foreign_id.json b/contracts/api/v2/fixtures/collection_order_foreign_id.json new file mode 100644 index 0000000000..e2c3737616 --- /dev/null +++ b/contracts/api/v2/fixtures/collection_order_foreign_id.json @@ -0,0 +1,14 @@ +{ + "type": "https://siloserver.org/docs/api/v2/problems/validation_failed", + "title": "Validation failed", + "status": 422, + "detail": "The request did not pass validation; see errors.", + "instance": "urn:silo:request:0000000000000000000002d8", + "errors": [ + { + "location": "body.ordered_ids", + "code": "invalid", + "detail": "ordered_ids must name each of your own collections exactly once" + } + ] +} diff --git a/contracts/api/v2/fixtures/create_collection_ok.json b/contracts/api/v2/fixtures/create_collection_ok.json index 8ec2ef7c52..cd2a69cb61 100644 --- a/contracts/api/v2/fixtures/create_collection_ok.json +++ b/contracts/api/v2/fixtures/create_collection_ok.json @@ -6,13 +6,13 @@ "description": "", "collection_type": "manual", "is_shared": false, - "allowed_profile_ids": [], "query_definition": {}, "sort_config": {}, "sort_order": 0, "group_id": null, "source_url": "", "sync_schedule": "", + "sync_cadence": "", "next_sync_at": null, "last_sync_at": null, "last_sync_status": "", @@ -21,6 +21,7 @@ "include_in_server_collections": false, "poster_url": "", "poster_thumbhash": "", + "poster_is_collage": false, "created_at": "2026-01-02T03:04:05.678Z", "updated_at": "2026-01-02T03:04:05.678Z" } diff --git a/contracts/api/v2/fixtures/create_collection_with_description_ok.json b/contracts/api/v2/fixtures/create_collection_with_description_ok.json new file mode 100644 index 0000000000..8112b6ac2b --- /dev/null +++ b/contracts/api/v2/fixtures/create_collection_with_description_ok.json @@ -0,0 +1,27 @@ +{ + "id": "c1", + "profile_id": "p-owner", + "creator_profile_id": "p-owner", + "name": "Rainy days", + "description": "For wet afternoons", + "collection_type": "manual", + "is_shared": false, + "query_definition": {}, + "sort_config": {}, + "sort_order": 0, + "group_id": null, + "source_url": "", + "sync_schedule": "", + "sync_cadence": "", + "next_sync_at": null, + "last_sync_at": null, + "last_sync_status": "", + "last_sync_message": "", + "item_count": 4, + "include_in_server_collections": false, + "poster_url": "", + "poster_thumbhash": "", + "poster_is_collage": false, + "created_at": "2026-01-02T03:04:05.678Z", + "updated_at": "2026-01-02T03:04:05.678Z" +} diff --git a/contracts/api/v2/fixtures/get_admin_collection_capabilities_ok.json b/contracts/api/v2/fixtures/get_admin_collection_capabilities_ok.json new file mode 100644 index 0000000000..cb94690452 --- /dev/null +++ b/contracts/api/v2/fixtures/get_admin_collection_capabilities_ok.json @@ -0,0 +1,22 @@ +{ + "revision": "f9622d80ff66956bdd64fcc3445fe57706ac2ce1c8b02df9e470959604c20337", + "state": "available", + "allowed": true, + "groups": true, + "imports": true, + "import_sources": [ + "mdblist", + "tmdb", + "tmdb_list" + ], + "artwork": true, + "item_reorder": true, + "template_summaries": true, + "mdblist_search": true, + "schedule_time_zone": { + "utc_offset": "-05:00", + "abbreviation": "CDT", + "name": "America/Chicago" + }, + "section_references": true +} diff --git a/contracts/api/v2/fixtures/get_admin_collection_ok.json b/contracts/api/v2/fixtures/get_admin_collection_ok.json index 4f1f20274c..d34ac3534f 100644 --- a/contracts/api/v2/fixtures/get_admin_collection_ok.json +++ b/contracts/api/v2/fixtures/get_admin_collection_ok.json @@ -14,6 +14,7 @@ "featured": false, "poster_url": "", "backdrop_url": "", + "poster_is_collage": false, "source_url": "", "query_definition": {}, "sort_config": {}, diff --git a/contracts/api/v2/fixtures/get_catalog_search_capabilities_ok.json b/contracts/api/v2/fixtures/get_catalog_search_capabilities_ok.json index bce0ddf7b5..d06d2b4368 100644 --- a/contracts/api/v2/fixtures/get_catalog_search_capabilities_ok.json +++ b/contracts/api/v2/fixtures/get_catalog_search_capabilities_ok.json @@ -1,8 +1,9 @@ { - "revision": "ec966ce0e901578a98aa51e9daae0db0ee834c5b59dd7845b80ecc81bcae7203", + "revision": "3e1c28b18db74bb3167f7331d3387693ae45f5305fe9749d7580c44022885a22", "state": "available", "allowed": true, "people_media_scope": true, + "facet_value_search": true, "person_prefetch": true, "video_with_episodes_scope": true, "provider": "meilisearch", diff --git a/contracts/api/v2/fixtures/get_collection_capabilities_ok.json b/contracts/api/v2/fixtures/get_collection_capabilities_ok.json new file mode 100644 index 0000000000..c06420115f --- /dev/null +++ b/contracts/api/v2/fixtures/get_collection_capabilities_ok.json @@ -0,0 +1,47 @@ +{ + "revision": "6a87601d9e894507e052de99b434f0e5ab0aac86724c3801b517fef58c4d5aa2", + "state": "available", + "allowed": true, + "groups": false, + "login_sharing": true, + "imports": false, + "import_sources": [], + "artwork": false, + "item_reorder": false, + "display_filter_fields": [ + "type", + "watched" + ], + "display_filter_presets": { + "watched": [ + "all", + "watched", + "unwatched" + ], + "media": [ + "all", + "movie", + "series" + ] + }, + "collection_default_sort": true, + "collection_sort_preferences": true, + "effective_collection_sort": true, + "sort_preference_kinds": [ + "library", + "user", + "watchlist", + "favorites" + ], + "create_description": true, + "mdblist_search": true, + "schedule_time_zone": { + "utc_offset": "-05:00", + "abbreviation": "CDT", + "name": "America/Chicago" + }, + "sync_schedule_editable": false, + "contains_item": true, + "preview_posters": true, + "poster_collages": false +} diff --git a/contracts/api/v2/fixtures/get_collection_ok.json b/contracts/api/v2/fixtures/get_collection_ok.json index 8ec2ef7c52..cd2a69cb61 100644 --- a/contracts/api/v2/fixtures/get_collection_ok.json +++ b/contracts/api/v2/fixtures/get_collection_ok.json @@ -6,13 +6,13 @@ "description": "", "collection_type": "manual", "is_shared": false, - "allowed_profile_ids": [], "query_definition": {}, "sort_config": {}, "sort_order": 0, "group_id": null, "source_url": "", "sync_schedule": "", + "sync_cadence": "", "next_sync_at": null, "last_sync_at": null, "last_sync_status": "", @@ -21,6 +21,7 @@ "include_in_server_collections": false, "poster_url": "", "poster_thumbhash": "", + "poster_is_collage": false, "created_at": "2026-01-02T03:04:05.678Z", "updated_at": "2026-01-02T03:04:05.678Z" } diff --git a/contracts/api/v2/fixtures/get_library_collections_ok.json b/contracts/api/v2/fixtures/get_library_collections_ok.json index a0cffb375b..0025dddaf5 100644 --- a/contracts/api/v2/fixtures/get_library_collections_ok.json +++ b/contracts/api/v2/fixtures/get_library_collections_ok.json @@ -30,7 +30,8 @@ "last_sync_message": "", "item_count": 12, "created_at": "2026-01-02T03:04:05.000Z", - "updated_at": "2026-01-02T03:04:05.000Z" + "updated_at": "2026-01-02T03:04:05.000Z", + "poster_is_collage": false } ], "groups": [ @@ -46,7 +47,8 @@ "title": "Rainy days", "poster_url": "", "item_count": 4, - "creator_profile_id": "p-owner" + "creator_profile_id": "p-owner", + "poster_is_collage": false } ] } @@ -59,7 +61,8 @@ "title": "Oscar Winners", "poster_url": "", "item_count": 12, - "featured": true + "featured": true, + "poster_is_collage": false } ] } diff --git a/contracts/api/v2/fixtures/get_profile_section_settings_ok.json b/contracts/api/v2/fixtures/get_profile_section_settings_ok.json index cc2b63b708..8a19f5a287 100644 --- a/contracts/api/v2/fixtures/get_profile_section_settings_ok.json +++ b/contracts/api/v2/fixtures/get_profile_section_settings_ok.json @@ -3,7 +3,8 @@ { "id": "s-continue", "section_type": "continue_watching", - "title": "Continue Watching", + "title": "Keep watching", + "default_title": "Continue Watching", "featured": false, "item_limit": 20, "hidden": true, @@ -15,6 +16,7 @@ "id": "u-gems", "section_type": "hidden_gems", "title": "Hidden gems", + "default_title": "", "featured": false, "item_limit": 12, "hidden": false, diff --git a/contracts/api/v2/fixtures/index.json b/contracts/api/v2/fixtures/index.json index 4d6a901186..88e2b76ad9 100644 --- a/contracts/api/v2/fixtures/index.json +++ b/contracts/api/v2/fixtures/index.json @@ -4149,27 +4149,48 @@ "body_file": "clear_file_marker_segment_ok.json" }, { - "name": "collection_group_stale", + "name": "collection_group_unsupported", "operation_id": "updateCollectionGroup", - "scenario": "A stale group edit leaves the resource unchanged and supplies the current validator.", + "scenario": "Personal collection groups are no longer supported; every group operation answers capability_unsupported.", "request": { "method": "PATCH", "path": "/api/v2/collections/groups/g1", "headers": { "Authorization": "Bearer tok-member", - "If-Match": "\"stale\"", + "If-Match": "*", "X-Profile-Id": "p-owner" }, "body": "{\"name\":\"Winter\"}" }, - "expected_status": 412, + "expected_status": 501, "response_headers": { - "Content-Type": "application/problem+json", - "ETag": "\"a398556adb45deecf5779ee6.25gpp42zguq5f\"" + "Content-Type": "application/problem+json" }, "response_media_type": "application/problem+json", "schema": "#/components/schemas/Problem", - "body_file": "collection_group_stale.json" + "body_file": "collection_group_unsupported.json" + }, + { + "name": "collection_order_foreign_id", + "operation_id": "reorderCollections", + "scenario": "An order naming another profile's collection, even a shared one, is a validation failure at ordered_ids.", + "request": { + "method": "PUT", + "path": "/api/v2/collections/order", + "headers": { + "Authorization": "Bearer tok-member", + "If-Match": "*", + "X-Profile-Id": "p-owner" + }, + "body": "{\"ordered_ids\":[\"c2\",\"c1\"]}" + }, + "expected_status": 422, + "response_headers": { + "Content-Type": "application/problem+json" + }, + "response_media_type": "application/problem+json", + "schema": "#/components/schemas/Problem", + "body_file": "collection_order_foreign_id.json" }, { "name": "collection_order_precondition_required", @@ -4457,6 +4478,28 @@ "schema": "#/components/schemas/PersonalCollection", "body_file": "create_collection_ok.json" }, + { + "name": "create_collection_with_description_ok", + "operation_id": "createCollection", + "scenario": "A manual collection created with its description.", + "request": { + "method": "POST", + "path": "/api/v2/collections", + "headers": { + "Authorization": "Bearer tok-member", + "X-Profile-Id": "p-owner" + }, + "body": "{\"name\":\"Rainy days\",\"description\":\"For wet afternoons\",\"collection_type\":\"manual\"}" + }, + "expected_status": 201, + "response_headers": { + "Content-Type": "application/json", + "Location": "/api/v2/collections/c1" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/PersonalCollection", + "body_file": "create_collection_with_description_ok.json" + }, { "name": "create_library_ok", "operation_id": "createLibrary", @@ -5943,6 +5986,27 @@ "schema": "#/components/schemas/AccountPasswordCapability", "body_file": "get_account_password_capability_secondary_profile.json" }, + { + "name": "get_admin_collection_capabilities_ok", + "operation_id": "getAdminCollectionCapabilities", + "scenario": "Administrator collection features, including MDBList search and the time zone the answering node runs cron schedules in.", + "request": { + "method": "GET", + "path": "/api/v2/admin/collections/capabilities", + "headers": { + "Authorization": "Bearer tok-admin" + } + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "private, no-cache", + "Content-Type": "application/json", + "ETag": "\"f9bfba671c81dbcc510b4d4cc41d4c6feb58ad9165a71786799d47524f7d7217\"" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/AdminCollectionCapabilityOutputBody", + "body_file": "get_admin_collection_capabilities_ok.json" + }, { "name": "get_admin_collection_ok", "operation_id": "getAdminCollection", @@ -6308,6 +6372,28 @@ "schema": "#/components/schemas/CatalogSearchCapabilities", "body_file": "get_catalog_search_capabilities_ok.json" }, + { + "name": "get_collection_capabilities_ok", + "operation_id": "getCollectionCapabilities", + "scenario": "Personal collection features, including a description on create, MDBList search, the time zone the answering node runs cron schedules in, and preview posters.", + "request": { + "method": "GET", + "path": "/api/v2/collections/capabilities", + "headers": { + "Authorization": "Bearer tok-member", + "X-Profile-Id": "p-owner" + } + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "private, no-cache", + "Content-Type": "application/json", + "ETag": "\"d1ab2d090e59fc1b188200e07381aad6e929278904a8e25da475b7882cdf3815\"" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/CollectionCapabilities", + "body_file": "get_collection_capabilities_ok.json" + }, { "name": "get_collection_items_ok", "operation_id": "getCollectionItems", @@ -8449,6 +8535,63 @@ "schema": "#/components/schemas/AccountIdentityCollection", "body_file": "list_account_identities_ok.json" }, + { + "name": "list_admin_collection_sections_ok", + "operation_id": "listAdminCollectionSections", + "scenario": "Rows that show a collection: Home rows first with a null library, including a starter-pack hero row, then library page rows, each with its page's row count.", + "request": { + "method": "GET", + "path": "/api/v2/admin/collections/c1/sections", + "headers": { + "Authorization": "Bearer tok-admin" + } + }, + "expected_status": 200, + "response_headers": { + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/CollectionAdminCollectionSection", + "body_file": "list_admin_collection_sections_ok.json" + }, + { + "name": "list_admin_collection_template_bundles_ok", + "operation_id": "listAdminCollectionTemplateBundles", + "scenario": "Template bundles with a summary of each template in bundle order; a template that cannot sync until an administrator sets its source needs setup.", + "request": { + "method": "GET", + "path": "/api/v2/admin/collections/template-bundles", + "headers": { + "Authorization": "Bearer tok-admin" + } + }, + "expected_status": 200, + "response_headers": { + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/BundleCatalog", + "body_file": "list_admin_collection_template_bundles_ok.json" + }, + { + "name": "list_admin_collections_ok", + "operation_id": "listAdminCollections", + "scenario": "Administrator collections with the number of Home rows and of all administrator page rows that show each one; a collection no row shows counts zero.", + "request": { + "method": "GET", + "path": "/api/v2/admin/collections?library_id=1", + "headers": { + "Authorization": "Bearer tok-admin" + } + }, + "expected_status": 200, + "response_headers": { + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/AdminCollectionList", + "body_file": "list_admin_collections_ok.json" + }, { "name": "list_admin_playback_history_ok", "operation_id": "listAdminPlaybackHistory", @@ -8974,10 +9117,31 @@ "schema": "#/components/schemas/Problem", "body_file": "list_catalog_items_unknown_sort_field.json" }, + { + "name": "list_collections_contains_item_ok", + "operation_id": "listCollections", + "scenario": "Visible personal collections, each of the profile's own manual collections marked with whether it holds the title.", + "request": { + "method": "GET", + "path": "/api/v2/collections?contains_item=movie-1", + "headers": { + "Authorization": "Bearer tok-member", + "X-Profile-Id": "p-owner" + } + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/PersonalCollectionCollection", + "body_file": "list_collections_contains_item_ok.json" + }, { "name": "list_collections_ok", "operation_id": "listCollections", - "scenario": "Visible personal collections and account groups.", + "scenario": "The acting profile's own collections, then another profile's shared collection; groups is always empty.", "request": { "method": "GET", "path": "/api/v2/collections", @@ -12134,6 +12298,27 @@ "schema": "#/components/schemas/Problem", "body_file": "precondition_required.json" }, + { + "name": "preview_collection_ok", + "operation_id": "previewCollection", + "scenario": "A smart query's first matches within the acting profile's access, each with its poster when it has one.", + "request": { + "method": "POST", + "path": "/api/v2/collections/preview", + "headers": { + "Authorization": "Bearer tok-member", + "X-Profile-Id": "p-owner" + }, + "body": "{\"query_definition\":{\"match\":\"all\",\"groups\":[]},\"limit\":2}" + }, + "expected_status": 200, + "response_headers": { + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/PersonalCollectionPreviewOutputBody", + "body_file": "preview_collection_ok.json" + }, { "name": "profile_verification_required", "operation_id": null, diff --git a/contracts/api/v2/fixtures/list_admin_collection_sections_ok.json b/contracts/api/v2/fixtures/list_admin_collection_sections_ok.json new file mode 100644 index 0000000000..57e1657bd7 --- /dev/null +++ b/contracts/api/v2/fixtures/list_admin_collection_sections_ok.json @@ -0,0 +1,37 @@ +{ + "items": [ + { + "id": "s-hero", + "scope": "home", + "library_id": null, + "section_type": "collection", + "title": "Starter pack hero", + "featured": true, + "enabled": true, + "position": 0, + "page_row_count": 9 + }, + { + "id": "s-home", + "scope": "home", + "library_id": null, + "section_type": "collection", + "title": "On Home", + "featured": false, + "enabled": true, + "position": 4, + "page_row_count": 9 + }, + { + "id": "s-kids", + "scope": "library", + "library_id": "7", + "section_type": "collection", + "title": "Kids", + "featured": false, + "enabled": false, + "position": 2, + "page_row_count": 3 + } + ] +} diff --git a/contracts/api/v2/fixtures/list_admin_collection_template_bundles_ok.json b/contracts/api/v2/fixtures/list_admin_collection_template_bundles_ok.json new file mode 100644 index 0000000000..e3cc48b880 --- /dev/null +++ b/contracts/api/v2/fixtures/list_admin_collection_template_bundles_ok.json @@ -0,0 +1,32 @@ +{ + "bundles": [ + { + "id": "franchise_collections", + "title": "Franchise Collections", + "description": "TMDB franchise and saga collections.", + "template_ids": [ + "tmdb_franchise_star_wars", + "tmdb_franchise_placeholder" + ], + "templates": [ + { + "id": "tmdb_franchise_star_wars", + "title": "Star Wars", + "source": "tmdb_collection", + "media_kind": "movie", + "featured": true, + "poster_path": "/images/collection-templates/tmdb_franchise_star_wars.jpg", + "needs_setup": false + }, + { + "id": "tmdb_franchise_placeholder", + "title": "TMDB Franchise", + "source": "tmdb_collection", + "media_kind": "movie", + "featured": false, + "needs_setup": true + } + ] + } + ] +} diff --git a/contracts/api/v2/fixtures/list_admin_collections_ok.json b/contracts/api/v2/fixtures/list_admin_collections_ok.json new file mode 100644 index 0000000000..773a0ff281 --- /dev/null +++ b/contracts/api/v2/fixtures/list_admin_collections_ok.json @@ -0,0 +1,101 @@ +{ + "items": [ + { + "id": "c1", + "library_id": "1", + "library_ids": [ + "1" + ], + "slug": "on-home", + "title": "On Home", + "description": "", + "collection_type": "manual", + "visibility": "visible", + "sort_order": 0, + "group_id": null, + "featured": false, + "poster_url": "", + "backdrop_url": "", + "poster_is_collage": false, + "source_url": "", + "query_definition": {}, + "sort_config": {}, + "source_config": {}, + "management_mode": "manual", + "management_source": "", + "management_key": "", + "last_sync_status": "", + "last_sync_message": "", + "item_count": 0, + "home_row_count": 2, + "row_count": 3, + "created_at": "2026-01-02T03:04:05.000Z", + "updated_at": "2026-01-02T03:04:05.000Z" + }, + { + "id": "c2", + "library_id": "1", + "library_ids": [ + "1" + ], + "slug": "library-page-only", + "title": "Library page only", + "description": "", + "collection_type": "smart", + "visibility": "visible", + "sort_order": 0, + "group_id": null, + "featured": false, + "poster_url": "", + "backdrop_url": "", + "poster_is_collage": false, + "source_url": "", + "query_definition": {}, + "sort_config": {}, + "source_config": {}, + "management_mode": "manual", + "management_source": "", + "management_key": "", + "last_sync_status": "", + "last_sync_message": "", + "item_count": 0, + "home_row_count": 0, + "row_count": 1, + "created_at": "2026-01-02T03:04:05.000Z", + "updated_at": "2026-01-02T03:04:05.000Z" + }, + { + "id": "c3", + "library_id": "1", + "library_ids": [ + "1" + ], + "slug": "unused", + "title": "Unused", + "description": "", + "collection_type": "manual", + "visibility": "visible", + "sort_order": 0, + "group_id": null, + "featured": false, + "poster_url": "", + "backdrop_url": "", + "poster_is_collage": false, + "source_url": "", + "query_definition": {}, + "sort_config": {}, + "source_config": {}, + "management_mode": "manual", + "management_source": "", + "management_key": "", + "last_sync_status": "", + "last_sync_message": "", + "item_count": 0, + "home_row_count": 0, + "row_count": 0, + "created_at": "2026-01-02T03:04:05.000Z", + "updated_at": "2026-01-02T03:04:05.000Z" + } + ], + "groups": [] +} diff --git a/contracts/api/v2/fixtures/list_collections_contains_item_ok.json b/contracts/api/v2/fixtures/list_collections_contains_item_ok.json new file mode 100644 index 0000000000..a1399dbf33 --- /dev/null +++ b/contracts/api/v2/fixtures/list_collections_contains_item_ok.json @@ -0,0 +1,60 @@ +{ + "items": [ + { + "id": "c1", + "profile_id": "p-owner", + "creator_profile_id": "p-owner", + "name": "Rainy days", + "description": "", + "collection_type": "manual", + "is_shared": false, + "query_definition": {}, + "sort_config": {}, + "sort_order": 0, + "group_id": null, + "source_url": "", + "sync_schedule": "", + "sync_cadence": "", + "next_sync_at": null, + "last_sync_at": null, + "last_sync_status": "", + "last_sync_message": "", + "item_count": 4, + "include_in_server_collections": false, + "poster_url": "", + "poster_thumbhash": "", + "poster_is_collage": false, + "created_at": "2026-01-02T03:04:05.678Z", + "updated_at": "2026-01-02T03:04:05.678Z", + "contains": true + }, + { + "id": "c2", + "profile_id": "p-primary", + "creator_profile_id": "p-primary", + "name": "Family night", + "description": "", + "collection_type": "manual", + "is_shared": true, + "query_definition": {}, + "sort_config": {}, + "sort_order": 0, + "group_id": null, + "source_url": "", + "sync_schedule": "", + "sync_cadence": "", + "next_sync_at": null, + "last_sync_at": null, + "last_sync_status": "", + "last_sync_message": "", + "item_count": 4, + "include_in_server_collections": false, + "poster_url": "", + "poster_thumbhash": "", + "poster_is_collage": false, + "created_at": "2026-01-02T03:04:05.678Z", + "updated_at": "2026-01-02T03:04:05.678Z" + } + ], + "groups": [] +} diff --git a/contracts/api/v2/fixtures/list_collections_ok.json b/contracts/api/v2/fixtures/list_collections_ok.json index 66825e1aa8..f10428e371 100644 --- a/contracts/api/v2/fixtures/list_collections_ok.json +++ b/contracts/api/v2/fixtures/list_collections_ok.json @@ -8,13 +8,13 @@ "description": "", "collection_type": "manual", "is_shared": false, - "allowed_profile_ids": [], "query_definition": {}, "sort_config": {}, "sort_order": 0, "group_id": null, "source_url": "", "sync_schedule": "", + "sync_cadence": "", "next_sync_at": null, "last_sync_at": null, "last_sync_status": "", @@ -23,6 +23,34 @@ "include_in_server_collections": false, "poster_url": "", "poster_thumbhash": "", + "poster_is_collage": false, + "created_at": "2026-01-02T03:04:05.678Z", + "updated_at": "2026-01-02T03:04:05.678Z" + }, + { + "id": "c2", + "profile_id": "p-primary", + "creator_profile_id": "p-primary", + "name": "Family night", + "description": "", + "collection_type": "manual", + "is_shared": true, + "query_definition": {}, + "sort_config": {}, + "sort_order": 0, + "group_id": null, + "source_url": "", + "sync_schedule": "", + "sync_cadence": "", + "next_sync_at": null, + "last_sync_at": null, + "last_sync_status": "", + "last_sync_message": "", + "item_count": 4, + "include_in_server_collections": false, + "poster_url": "", + "poster_thumbhash": "", + "poster_is_collage": false, "created_at": "2026-01-02T03:04:05.678Z", "updated_at": "2026-01-02T03:04:05.678Z" } diff --git a/contracts/api/v2/fixtures/list_library_user_collections_ok.json b/contracts/api/v2/fixtures/list_library_user_collections_ok.json index 1005afca85..9fc098f444 100644 --- a/contracts/api/v2/fixtures/list_library_user_collections_ok.json +++ b/contracts/api/v2/fixtures/list_library_user_collections_ok.json @@ -6,6 +6,7 @@ "name": "Rainy days", "collection_type": "manual", "item_count": 4, + "poster_is_collage": false, "created_at": "2026-01-02T03:04:05.000Z", "updated_at": "2026-01-02T03:04:05.000Z" } diff --git a/contracts/api/v2/fixtures/preview_collection_ok.json b/contracts/api/v2/fixtures/preview_collection_ok.json new file mode 100644 index 0000000000..4589bbfabc --- /dev/null +++ b/contracts/api/v2/fixtures/preview_collection_ok.json @@ -0,0 +1,16 @@ +{ + "items": [ + { + "content_id": "movie:heat-1995", + "title": "Heat", + "type": "movie", + "poster_url": "https://img.example.test/heat.jpg" + }, + { + "content_id": "movie:ronin-1998", + "title": "Ronin", + "type": "movie" + } + ], + "total": 2 +} diff --git a/contracts/api/v2/fixtures/search_catalog_facet_ok.json b/contracts/api/v2/fixtures/search_catalog_facet_ok.json index c3803f07b0..9d18d3dba9 100644 --- a/contracts/api/v2/fixtures/search_catalog_facet_ok.json +++ b/contracts/api/v2/fixtures/search_catalog_facet_ok.json @@ -2,5 +2,12 @@ "matches": [ "Frank Herbert" ], - "has_more": true + "has_more": true, + "values": [ + { + "value": "Frank Herbert", + "count": 6 + } + ], + "values_has_more": true } diff --git a/contracts/api/v2/migration.json b/contracts/api/v2/migration.json index fa639db4bf..b28fa7efdb 100644 --- a/contracts/api/v2/migration.json +++ b/contracts/api/v2/migration.json @@ -15988,18 +15988,18 @@ "consumer_call_sites": [], "section": "admin-settings", "release_flow": "core_admin", - "tier": 1, - "disposition": "ported", - "disposition_rule": "default_ported", - "disposition_rationale": "No exclusion, removal, or ratified redesign applies; port to v2 in its section PR.", - "owner": null, + "tier": 2, + "disposition": "removed", + "disposition_rule": "v1_scope_removals_table", + "disposition_rationale": "Listed in docs/architecture/v1-scope.md removals (\"The `sections.allow_profile_custom_sections` admin setting routes\"); still registered on the bridge surface, retired by the tombstone. No successor: profiles may always add rule rows, so there is no setting.", + "owner": "Quick104", "review_state": "ratified", "v2": { - "method": "GET", - "path": "/api/v2/admin/settings/sections", - "operation_id": "getAdminSectionSettings" + "method": null, + "path": null, + "operation_id": null }, - "notes": "Reuse existing profile-section flag foundation. Store read errors preserve disabled default; no recorded native/web consumer. Independent review pending." + "notes": "The v2 operations (getAdminSectionSettings, updateAdminSectionSettings) were removed before the v2 lock when the \"Let profiles add rule rows\" setting was retired: profiles may always add rule rows, and only an admin adds new Editor's picks rows. The frozen v1 route reads allow_profile_custom_sections as true; a stored sections.allow_profile_custom_sections row is ignored. getProfileSectionFlags keeps allow_profile_custom_sections, always true and deprecated. No native or Jellyfin consumer exists. Removal ratified 2026-10-05 (owner decision, Quick104)." }, { "listener": "api", @@ -16040,21 +16040,18 @@ "consumer_call_sites": [], "section": "admin-settings", "release_flow": "core_admin", - "tier": 1, - "disposition": "ported", - "disposition_rule": "default_ported", - "disposition_rationale": "No exclusion, removal, or ratified redesign applies; port to v2 in its section PR.", - "owner": null, + "tier": 2, + "disposition": "removed", + "disposition_rule": "v1_scope_removals_table", + "disposition_rationale": "Listed in docs/architecture/v1-scope.md removals (\"The `sections.allow_profile_custom_sections` admin setting routes\"); still registered on the bridge surface, retired by the tombstone. No successor: profiles may always add rule rows, so there is no setting.", + "owner": "Quick104", "review_state": "ratified", "v2": { - "method": "PUT", - "path": "/api/v2/admin/settings/sections", - "operation_id": "updateAdminSectionSettings" + "method": null, + "path": null, + "operation_id": null }, - "notes": "Guarded boolean replacement through existing settings UpdateAtomic. No-change no write; admin GET now supplies actor/profile-bound ETag and conditional reads. Existing profile read/default-on-error semantics and bridge writer unchanged. No recorded web/native/Jellyfin writer; no UI invented. Independent new-write/additive-admin-read review pending.", - "concurrency": "if_match", - "retry_safety": "natural_idempotent", - "retry_safety_note": "Same desired boolean has no additional effect; canonical guard runs inside the settings transaction. Reusing a stale explicit validator fails rather than overwriting a changed flag." + "notes": "The v2 operations (getAdminSectionSettings, updateAdminSectionSettings) were removed before the v2 lock when the \"Let profiles add rule rows\" setting was retired: profiles may always add rule rows, and only an admin adds new Editor's picks rows. The frozen v1 route accepts a JSON body, changes nothing, and answers allow_profile_custom_sections true; a stored sections.allow_profile_custom_sections row is ignored. getProfileSectionFlags keeps allow_profile_custom_sections, always true and deprecated. No native or Jellyfin consumer exists. Removal ratified 2026-10-05 (owner decision, Quick104)." }, { "listener": "api", @@ -35186,7 +35183,7 @@ "path": "/api/v2/profile/sections/settings", "operation_id": "getProfileSectionSettings" }, - "notes": "Conditionally registered: authMiddleware != nil; sectionHandler != nil. v2 registers the operation unconditionally and reports capability state instead. Path keeps the v1 shape. Collection envelope (items) instead of {sections}; config is an extension bag." + "notes": "Conditionally registered: authMiddleware != nil; sectionHandler != nil. v2 registers the operation unconditionally and reports capability state instead. Path keeps the v1 shape. Collection envelope (items) instead of {sections}; config is an extension bag. Adds default_title, the administrator's title for the row, which v1 does not return." }, { "listener": "api", @@ -38254,17 +38251,9 @@ "upgrades_websocket": false, "capability_endpoint": null, "consumers": [ - "web" - ], - "consumer_call_sites": [ - { - "repo": "web", - "file": "src/lib/recipes.ts", - "line": 79, - "types": [], - "match": "mechanical" - } + "unused" ], + "consumer_call_sites": [], "section": "catalog-home", "release_flow": "browse_search", "tier": 1, diff --git a/contracts/api/v2/openapi.json b/contracts/api/v2/openapi.json index 307956fb3a..4751c40c5a 100644 --- a/contracts/api/v2/openapi.json +++ b/contracts/api/v2/openapi.json @@ -3394,6 +3394,12 @@ "null" ] }, + "home_row_count": { + "description": "Turned-on rows on the administrator Home page that show this collection; turned-off rows are not counted. Set on listAdminCollections items only. Rows profiles added themselves are not counted.", + "format": "int64", + "minimum": 0, + "type": "integer" + }, "id": { "description": "Opaque identifier", "examples": [ @@ -3455,6 +3461,13 @@ "patternDescription": "a non-zero RFC 3339 instant", "type": "string" }, + "poster_is_collage": { + "description": "poster_url is the acting profile's collage of the collection's first members it can access, composed by the server because the collection has no uploaded or template poster. False for an uploaded or template poster and whenever poster_url is empty, including before the collage is built and on reads that carry no poster_url", + "examples": [ + false + ], + "type": "boolean" + }, "poster_thumbhash": { "type": "string" }, @@ -3462,6 +3475,12 @@ "type": "string" }, "query_definition": {}, + "row_count": { + "description": "Rows on the administrator Home and library pages that show this collection, turned-off rows included. Set on listAdminCollections items only. Rows profiles added themselves are not counted.", + "format": "int64", + "minimum": 0, + "type": "integer" + }, "slug": { "type": "string" }, @@ -3505,6 +3524,7 @@ "featured", "poster_url", "backdrop_url", + "poster_is_collage", "source_url", "query_definition", "sort_config", @@ -3588,10 +3608,27 @@ "item_reorder": { "type": "boolean" }, + "mdblist_search": { + "description": "searchMDBListLists and listTopMDBListLists return lists; false when the server has no MDBList API key", + "examples": [ + true + ], + "type": "boolean" + }, "revision": { "description": "Opaque revision of this document", "type": "string" }, + "schedule_time_zone": { + "$ref": "#/components/schemas/CollectionScheduleTimeZone" + }, + "section_references": { + "description": "listAdminCollectionSections lists the rows that show a collection, and listAdminCollections items carry home_row_count and row_count", + "examples": [ + true + ], + "type": "boolean" + }, "state": { "description": "Support and configuration state, not health", "enum": [ @@ -3601,6 +3638,13 @@ "unsupported" ], "type": "string" + }, + "template_summaries": { + "description": "listAdminCollectionTemplateBundles returns each bundle's templates", + "examples": [ + true + ], + "type": "boolean" } }, "required": [ @@ -3609,6 +3653,10 @@ "import_sources", "artwork", "item_reorder", + "template_summaries", + "mdblist_search", + "schedule_time_zone", + "section_references", "revision", "state", "allowed" @@ -4000,6 +4048,71 @@ ], "type": "object" }, + "AdminCollectionSection": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "featured": { + "description": "The row is its page's hero banner.", + "type": "boolean" + }, + "id": { + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + }, + "library_id": { + "description": "The library whose page holds the row; null for a Home row.", + "examples": [ + "1" + ], + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "page_row_count": { + "description": "Rows on the page that holds this row, turned-off rows included.", + "format": "int64", + "type": "integer" + }, + "position": { + "format": "int64", + "type": "integer" + }, + "scope": { + "enum": [ + "home", + "library" + ], + "type": "string" + }, + "section_type": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "id", + "scope", + "library_id", + "section_type", + "title", + "featured", + "enabled", + "position", + "page_row_count" + ], + "type": "object" + }, "AdminCollectionSyncRun": { "additionalProperties": false, "properties": { @@ -20259,20 +20372,57 @@ "additionalProperties": false, "properties": { "has_more": { - "description": "Whether more values matched than limit", + "description": "Whether more values than limit start with q", "type": "boolean" }, "matches": { - "description": "Empty, never null", + "description": "Values that start with q, case-insensitively, A-Z; empty for an empty q. Empty, never null", "items": { "type": "string" }, "type": "array" + }, + "values": { + "description": "The ranked answer, each value with its title count. For genre, studio, network, country, original_language and content_rating a value matches when it or any word in it starts with q; whole-value matches rank first, then more titles, then A-Z, and an empty q returns the most common values. For author, narrator and series these are the names in matches. Empty, never null", + "items": { + "$ref": "#/components/schemas/CatalogFacetValue" + }, + "type": "array" + }, + "values_has_more": { + "description": "Whether more values matched than limit for values", + "type": "boolean" } }, "required": [ "matches", - "has_more" + "has_more", + "values", + "values_has_more" + ], + "type": "object" + }, + "CatalogFacetValue": { + "additionalProperties": false, + "properties": { + "count": { + "description": "Titles in the scope with this value; values can lag catalog changes by up to two minutes", + "examples": [ + 42 + ], + "format": "int64", + "type": "integer" + }, + "value": { + "examples": [ + "Warner Bros. Pictures" + ], + "type": "string" + } + }, + "required": [ + "value", + "count" ], "type": "object" }, @@ -21528,6 +21678,10 @@ "description": "Whether the current principal may use the capability", "type": "boolean" }, + "facet_value_search": { + "description": "searchCatalogFacet accepts library_ids and answers values and values_has_more: ranked values with title counts that match word starts, and the most common values for an empty q", + "type": "boolean" + }, "max_sessions_per_account": { "description": "Oldest ranking sessions expire when this retention bound is exceeded", "format": "int64", @@ -22087,6 +22241,26 @@ ], "type": "object" }, + "CollectionAdminCollectionSection": { + "additionalProperties": false, + "properties": { + "items": { + "description": "The page's items; empty, never null", + "items": { + "$ref": "#/components/schemas/AdminCollectionSection" + }, + "type": "array" + }, + "page": { + "$ref": "#/components/schemas/PageInfo", + "description": "Cursor state; absent for bounded unpaginated collections" + } + }, + "required": [ + "items" + ], + "type": "object" + }, "CollectionAdminDeviceMetadata": { "additionalProperties": false, "properties": { @@ -22750,6 +22924,20 @@ ], "type": "boolean" }, + "contains_item": { + "description": "listCollections accepts contains_item and marks the acting profile's own manual collections with contains", + "examples": [ + true + ], + "type": "boolean" + }, + "create_description": { + "description": "createCollection stores a description for the acting account", + "examples": [ + true + ], + "type": "boolean" + }, "display_filter_fields": { "description": "Catalog query fields a display filter may use", "examples": [ @@ -22773,7 +22961,7 @@ "type": "boolean" }, "groups": { - "description": "The acting account supports collection groups", + "description": "Always false: personal collection groups are no longer supported", "type": "boolean" }, "import_sources": { @@ -22803,10 +22991,38 @@ "description": "The acting account supports reordering collection items", "type": "boolean" }, + "login_sharing": { + "description": "is_shared shows a collection to every profile on the login, listCollections includes other profiles' shared collections, and only a collection's creator changes or orders it", + "type": "boolean" + }, + "mdblist_search": { + "description": "searchMDBListLists and listTopMDBListLists return lists; false when the server has no MDBList API key", + "examples": [ + true + ], + "type": "boolean" + }, + "poster_collages": { + "description": "A collection with no uploaded or imported poster shows a collage of its first titles the acting profile can see in poster_url, marked by poster_is_collage, on listCollections, getLibraryCollections and listLibraryUserCollections, once the server has built it. False when the server has no artwork storage or the acting account's store keeps no artwork", + "examples": [ + true + ], + "type": "boolean" + }, + "preview_posters": { + "description": "previewCollection items carry poster_url when the title has a poster", + "examples": [ + true + ], + "type": "boolean" + }, "revision": { "description": "Opaque revision of this document", "type": "string" }, + "schedule_time_zone": { + "$ref": "#/components/schemas/CollectionScheduleTimeZone" + }, "sort_preference_kinds": { "description": "collection_kind values the sort-preference operations accept", "examples": [ @@ -22831,10 +23047,18 @@ "unsupported" ], "type": "string" + }, + "sync_schedule_editable": { + "description": "updateCollection accepts sync_schedule on a synced list; false when imports is false", + "examples": [ + true + ], + "type": "boolean" } }, "required": [ "groups", + "login_sharing", "imports", "import_sources", "artwork", @@ -22845,6 +23069,13 @@ "collection_sort_preferences", "effective_collection_sort", "sort_preference_kinds", + "create_description", + "mdblist_search", + "schedule_time_zone", + "sync_schedule_editable", + "contains_item", + "preview_posters", + "poster_collages", "revision", "state", "allowed" @@ -23217,7 +23448,7 @@ "additionalProperties": false, "properties": { "group_id": { - "description": "The group whose collections are ordered; omitted or null orders the ungrouped section", + "description": "Omit or send null: personal collection groups are no longer supported, and any other value is a validation failure", "examples": [ "g1" ], @@ -23228,7 +23459,7 @@ ] }, "ordered_ids": { - "description": "Every visible collection in the scope, exactly once, in the new order", + "description": "Each of the acting profile's own collections, exactly once, in the new order; another profile's collection is a validation failure", "examples": [ [ "01J9Z8C3W4R5T6Y7U8I9O0P1Q4" @@ -23310,6 +23541,38 @@ ], "type": "object" }, + "CollectionScheduleTimeZone": { + "additionalProperties": false, + "properties": { + "abbreviation": { + "description": "Current zone abbreviation as the node's time zone database reports it; some zones report a numeric form such as -03", + "examples": [ + "CDT" + ], + "type": "string" + }, + "name": { + "description": "IANA zone name, from the node's TZ environment variable or UTC when TZ is empty or names no known zone; omitted when the node uses its system default zone or a TZ file path", + "examples": [ + "America/Chicago" + ], + "type": "string" + }, + "utc_offset": { + "description": "Current offset from UTC, daylight saving time included", + "examples": [ + "-05:00" + ], + "pattern": "^[+-][0-9]{2}:[0-9]{2}$", + "type": "string" + } + }, + "required": [ + "utc_offset", + "abbreviation" + ], + "type": "object" + }, "CollectionSortPreference": { "additionalProperties": false, "properties": { @@ -23505,11 +23768,19 @@ }, "type": "array" }, + "templates": { + "description": "The bundle's templates, in template_ids order.", + "items": { + "$ref": "#/components/schemas/CollectionTemplateSummary" + }, + "type": "array" + }, "title": { "type": "string" } }, "required": [ + "templates", "id", "title", "description", @@ -23532,6 +23803,44 @@ ], "type": "object" }, + "CollectionTemplateSummary": { + "additionalProperties": false, + "properties": { + "featured": { + "description": "Collections created from this template are pinned first on their shelf.", + "type": "boolean" + }, + "id": { + "type": "string" + }, + "media_kind": { + "type": "string" + }, + "needs_setup": { + "description": "Applying the template creates an empty collection that cannot sync until an administrator sets its source.", + "type": "boolean" + }, + "poster_path": { + "description": "Server-relative path of the template's poster image.", + "type": "string" + }, + "source": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "id", + "title", + "source", + "media_kind", + "featured", + "needs_setup" + ], + "type": "object" + }, "CollectionUserLibrary": { "additionalProperties": false, "properties": { @@ -23920,6 +24229,13 @@ "patternDescription": "a non-zero RFC 3339 instant", "type": "string" }, + "poster_is_collage": { + "description": "poster_url is the acting profile's collage of the collection's first titles it can see, composed by the server because the collection has no uploaded, template or imported poster. False for an uploaded, template or imported poster and whenever poster_url is empty. See getCollectionCapabilities poster_collages", + "examples": [ + false + ], + "type": "boolean" + }, "poster_thumbhash": { "type": "string" }, @@ -24004,7 +24320,8 @@ "last_sync_message", "item_count", "created_at", - "updated_at" + "updated_at", + "poster_is_collage" ], "type": "object" }, @@ -29708,6 +30025,13 @@ "format": "int64", "type": "integer" }, + "poster_is_collage": { + "description": "poster_url is the acting profile's collage of the collection's first titles it can see, composed by the server because the collection has no uploaded, template or imported poster. False for an uploaded, template or imported poster and whenever poster_url is empty. See getCollectionCapabilities poster_collages", + "examples": [ + false + ], + "type": "boolean" + }, "poster_thumbhash": { "type": "string" }, @@ -29726,7 +30050,8 @@ "id", "title", "poster_url", - "item_count" + "item_count", + "poster_is_collage" ], "type": "object" }, @@ -31219,13 +31544,14 @@ "description": "Display filter fragment" }, "is_shared": { + "description": "Show the collection to every profile on the login; defaults to false", "examples": [ false ], "type": "boolean" }, "library_ids": { - "description": "Libraries the sync matches against; every library when omitted", + "description": "Libraries the sync matches against, limited to the libraries the collection's owner can access; every library the owner can access when omitted", "examples": [ [ "1" @@ -35069,21 +35395,6 @@ "PersonalCollection": { "additionalProperties": false, "properties": { - "allowed_profile_ids": { - "description": "Profiles a shared collection is limited to; empty means every profile on the account", - "examples": [ - [] - ], - "items": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, - "type": "string" - }, - "type": "array" - }, "collection_type": { "description": "manual, smart, or an import source (mdblist, tmdb, trakt)", "examples": [ @@ -35091,6 +35402,13 @@ ], "type": "string" }, + "contains": { + "description": "Whether the collection holds the listCollections contains_item title. Present only when contains_item is sent, and then only on the acting profile's own manual collections; false for a title the profile cannot access", + "examples": [ + true + ], + "type": "boolean" + }, "created_at": { "description": "RFC 3339 instant in UTC with millisecond precision", "examples": [ @@ -35119,7 +35437,7 @@ "description": "Display filter fragment; absent when none" }, "group_id": { - "description": "null when ungrouped", + "description": "Always null: personal collection groups are no longer supported", "examples": [ "g1" ], @@ -35144,6 +35462,7 @@ "type": "boolean" }, "is_shared": { + "description": "Every profile on the login sees the collection read-only, including profiles added later; otherwise only its creator does", "examples": [ false ], @@ -35201,6 +35520,13 @@ "null" ] }, + "poster_is_collage": { + "description": "poster_url is a collage of the collection's first titles the acting profile can see, composed by the server because the collection has no uploaded or imported poster. False for an uploaded or imported poster and whenever poster_url is empty: before the collage is built, when no title the profile can see has a poster, and on getCollection and updateCollection, which carry no poster_url for any poster because their body sits behind a strong ETag. See getCollectionCapabilities poster_collages", + "examples": [ + false + ], + "type": "boolean" + }, "poster_thumbhash": { "examples": [ "" @@ -35229,6 +35555,7 @@ "description": "Default sort document; {} when unset" }, "sort_order": { + "description": "Position in its creator's own order of collections", "examples": [ 0 ], @@ -35245,6 +35572,20 @@ ], "type": "string" }, + "sync_cadence": { + "description": "The cadence sync_schedule names; empty when the collection is not synced, custom for a schedule no cadence name produces", + "enum": [ + "", + "daily", + "weekly", + "monthly", + "custom" + ], + "examples": [ + "" + ], + "type": "string" + }, "sync_schedule": { "description": "Empty when the collection is not synced", "examples": [ @@ -35271,13 +35612,13 @@ "description", "collection_type", "is_shared", - "allowed_profile_ids", "query_definition", "sort_config", "sort_order", "group_id", "source_url", "sync_schedule", + "sync_cadence", "next_sync_at", "last_sync_at", "last_sync_status", @@ -35286,6 +35627,7 @@ "include_in_server_collections", "poster_url", "poster_thumbhash", + "poster_is_collage", "created_at", "updated_at" ], @@ -35309,7 +35651,7 @@ "additionalProperties": false, "properties": { "groups": { - "description": "The account's collection groups in sort order; empty, never null", + "description": "Always empty: personal collection groups are no longer supported", "items": { "$ref": "#/components/schemas/CollectionGroup" }, @@ -35336,21 +35678,6 @@ "PersonalCollectionCreate": { "additionalProperties": false, "properties": { - "allowed_profile_ids": { - "description": "Profiles a shared collection is limited to", - "examples": [ - [] - ], - "items": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, - "type": "string" - }, - "type": "array" - }, "collection_type": { "description": "Defaults to manual", "enum": [ @@ -35362,6 +35689,13 @@ ], "type": "string" }, + "description": { + "description": "Empty when omitted. Send only when getCollectionCapabilities reports create_description; otherwise the request fails", + "examples": [ + "For wet afternoons" + ], + "type": "string" + }, "display_query_definition": { "description": "Display filter fragment" }, @@ -35372,6 +35706,7 @@ "type": "boolean" }, "is_shared": { + "description": "Show the collection to every profile on the login; defaults to false", "examples": [ false ], @@ -35511,6 +35846,10 @@ "minLength": 1, "type": "string" }, + "poster_url": { + "description": "Card-size poster URL; omitted when the item has none", + "type": "string" + }, "title": { "type": "string" }, @@ -35553,23 +35892,12 @@ "PersonalCollectionUpdate": { "additionalProperties": false, "properties": { - "allowed_profile_ids": { - "items": { - "description": "Opaque identifier", - "examples": [ - "1" - ], - "minLength": 1, - "type": "string" - }, - "type": "array" - }, "description": { "type": "string" }, "display_query_definition": {}, "group_id": { - "description": "Null removes the group; omitted leaves it unchanged", + "description": "Omit: personal collection groups are no longer supported, and setting it answers capability_unsupported", "examples": [ "1" ], @@ -35583,6 +35911,7 @@ "type": "boolean" }, "is_shared": { + "description": "Show the collection to every profile on the login", "type": "boolean" }, "library_ids": { @@ -35612,6 +35941,19 @@ "sort_config": {}, "source_url": { "type": "string" + }, + "sync_schedule": { + "description": "A synced list's cadence; empty stops scheduled syncs. Cron expressions are refused. Accepted when getCollectionCapabilities reports sync_schedule_editable", + "enum": [ + "", + "daily", + "weekly", + "monthly" + ], + "examples": [ + "weekly" + ], + "type": "string" } }, "type": "object" @@ -38371,9 +38713,10 @@ "additionalProperties": false, "properties": { "allow_profile_custom_sections": { - "description": "Whether non-admin profiles may build sections from admin-only recipes", + "deprecated": true, + "description": "Deprecated; always true. Profiles may always add rule rows (custom_filter). Whether a profile that is not an admin may add a new row of a recipe is that recipe's admin_only in listSectionRecipes; follow it instead of this flag. Kept for clients that still read it.", "examples": [ - false + true ], "type": "boolean" } @@ -38399,6 +38742,13 @@ ], "type": "boolean" }, + "default_title": { + "description": "The administrator's title for this row; title shows it unless the profile saved a title override. Empty for a section the profile built", + "examples": [ + "Continue Watching" + ], + "type": "string" + }, "featured": { "examples": [ false @@ -38447,6 +38797,7 @@ "type": "string" }, "title": { + "description": "The title this profile sees: its own title override, or the administrator's title", "examples": [ "Continue Watching" ], @@ -38457,6 +38808,7 @@ "id", "section_type", "title", + "default_title", "featured", "item_limit", "hidden", @@ -39619,6 +39971,7 @@ "additionalProperties": false, "properties": { "admin_only": { + "description": "Only an admin may add a new section of this recipe; a profile keeps and changes the ones it already has", "examples": [ false ], @@ -44236,13 +44589,14 @@ "description": "Display filter fragment" }, "is_shared": { + "description": "Show the collection to every profile on the login; defaults to false", "examples": [ false ], "type": "boolean" }, "library_ids": { - "description": "Libraries the sync matches against; every library when omitted", + "description": "Libraries the sync matches against, limited to the libraries the collection's owner can access; every library the owner can access when omitted", "examples": [ [ "1" @@ -44411,13 +44765,14 @@ "description": "Display filter fragment" }, "is_shared": { + "description": "Show the collection to every profile on the login; defaults to false", "examples": [ false ], "type": "boolean" }, "library_ids": { - "description": "Libraries the sync matches against; every library when omitted", + "description": "Libraries the sync matches against, limited to the libraries the collection's owner can access; every library the owner can access when omitted", "examples": [ [ "1" @@ -45012,13 +45367,14 @@ "description": "Display filter fragment" }, "is_shared": { + "description": "Show the collection to every profile on the login; defaults to false", "examples": [ false ], "type": "boolean" }, "library_ids": { - "description": "Libraries the sync matches against; every library when omitted", + "description": "Libraries the sync matches against, limited to the libraries the collection's owner can access; every library the owner can access when omitted", "examples": [ [ "1" @@ -45300,6 +45656,13 @@ ], "type": "string" }, + "poster_is_collage": { + "description": "poster_url is the acting profile's collage of the collection's first titles it can see, composed by the server because the collection has no uploaded, template or imported poster. False for an uploaded, template or imported poster and whenever poster_url is empty. See getCollectionCapabilities poster_collages", + "examples": [ + false + ], + "type": "boolean" + }, "poster_thumbhash": { "type": "string" }, @@ -45324,6 +45687,7 @@ "name", "collection_type", "item_count", + "poster_is_collage", "created_at", "updated_at" ], @@ -61740,7 +62104,7 @@ "bearerAuth": [] } ], - "summary": "List collection template bundles.", + "summary": "List collection template bundles, each with a summary of its templates.", "tags": [ "admin-collections" ], @@ -62279,7 +62643,7 @@ "bearerAuth": [] } ], - "summary": "List supported collection templates.", + "summary": "List the collection templates with an mdblist, tmdb or tmdb_list source.", "tags": [ "admin-collections" ], @@ -64475,6 +64839,176 @@ "x-silo-service-backed": true } }, + "/api/v2/admin/collections/{id}/sections": { + "get": { + "operationId": "listAdminCollectionSections", + "parameters": [ + { + "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", + "in": "header", + "name": "X-Profile-Id", + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", + "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], + "type": "string" + } + }, + { + "description": "Opaque identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + } + }, + { + "in": "header", + "name": "If-Match", + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "If-None-Match", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionAdminCollectionSection" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List the administrator Home and library page rows that show a collection. Rows profiles added themselves are not listed.", + "tags": [ + "admin-collections" + ], + "x-silo-class": "acting_admin", + "x-silo-service-backed": true + } + }, "/api/v2/admin/collections/{id}/sync": { "post": { "operationId": "syncAdminCollection", @@ -108035,18 +108569,10 @@ "x-silo-class": "acting_admin" } }, - "/api/v2/admin/settings/sections": { + "/api/v2/admin/settings/sensitive-status": { "get": { - "operationId": "getAdminSectionSettings", + "operationId": "getAdminSensitiveSettingsStatus", "parameters": [ - { - "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", - "in": "header", - "name": "If-Match", - "schema": { - "type": "string" - } - }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -108068,13 +108594,6 @@ ], "type": "string" } - }, - { - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } } ], "responses": { @@ -108082,30 +108601,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProfileSectionFlags" + "$ref": "#/components/schemas/AdminSensitiveStatus" } } }, - "description": "OK", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "The representation named by If-None-Match is current; no body.", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "400": { "content": { @@ -108157,7 +108657,7 @@ }, "description": "Not Acceptable" }, - "412": { + "422": { "content": { "application/problem+json": { "schema": { @@ -108165,16 +108665,150 @@ } } }, - "description": "Precondition Failed", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/problem+json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" } } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Read configured secret key names without returning secret values.", + "tags": [ + "admin-settings" + ], + "x-silo-class": "acting_admin", + "x-silo-service-backed": true + } + }, + "/api/v2/admin/settings/{key}": { + "get": { + "operationId": "getAdminSetting", + "parameters": [ + { + "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", + "in": "header", + "name": "X-Profile-Id", + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", + "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], + "type": "string" } }, + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "maxLength": 256, + "minLength": 1, + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminSettingReadOutputBody" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" + }, "422": { "content": { "application/problem+json": { @@ -108221,16 +108855,16 @@ "bearerAuth": [] } ], - "summary": "Read section permission configuration; read failures retain the disabled default.", + "summary": "Read one visible stored setting; protected, missing and empty values return not found.", "tags": [ "admin-settings" ], "x-silo-class": "acting_admin", - "x-silo-conditional": true, "x-silo-service-backed": true }, "put": { - "operationId": "updateAdminSectionSettings", + "description": "Turning auth.local_password_login off while no enabled break-glass admin can sign in with a password is 409 break_glass_required.", + "operationId": "updateAdminSetting", "parameters": [ { "description": "The resource's current ETag, or \"*\" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag.", @@ -108270,13 +108904,23 @@ ], "type": "string" } + }, + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "maxLength": 256, + "minLength": 1, + "type": "string" + } } ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProfileSectionFlags" + "$ref": "#/components/schemas/AdminSettingUpdateInputBody" } } }, @@ -108287,19 +108931,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProfileSectionFlags" + "$ref": "#/components/schemas/AdminSettingValue" } } }, - "description": "OK", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "400": { "content": { @@ -108361,6 +108997,16 @@ }, "description": "Request Timeout" }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, "412": { "content": { "application/problem+json": { @@ -108455,20 +109101,20 @@ "bearerAuth": [] } ], - "summary": "Replace section permission configuration under the existing settings transaction guard.", + "summary": "Validate and replace one setting with the established single-key validation rules and transaction guard.", "tags": [ "admin-settings" ], "x-silo-class": "acting_admin", "x-silo-demo-restricted": true, "x-silo-guarded": true, - "x-silo-retry-safety": "natural_idempotent", + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/settings/sensitive-status": { + "/api/v2/admin/stats": { "get": { - "operationId": "getAdminSensitiveSettingsStatus", + "operationId": "getAdminDashboardStats", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -108491,6 +109137,15 @@ ], "type": "string" } + }, + { + "explode": false, + "in": "query", + "name": "refresh", + "schema": { + "default": false, + "type": "boolean" + } } ], "responses": { @@ -108498,7 +109153,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminSensitiveStatus" + "$ref": "#/components/schemas/AdminDashboardStats" } } }, @@ -108600,17 +109255,17 @@ "bearerAuth": [] } ], - "summary": "Read configured secret key names without returning secret values.", + "summary": "Read overall dashboard statistics using the existing provider cache and fallback.", "tags": [ - "admin-settings" + "admin-observability" ], "x-silo-class": "acting_admin", "x-silo-service-backed": true } }, - "/api/v2/admin/settings/{key}": { + "/api/v2/admin/stats/downloads": { "get": { - "operationId": "getAdminSetting", + "operationId": "getAdminDashboardDownloadsStats", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -108635,13 +109290,24 @@ } }, { - "in": "path", - "name": "key", - "required": true, + "explode": false, + "in": "query", + "name": "limit", "schema": { - "maxLength": 256, - "minLength": 1, - "type": "string" + "default": 10, + "format": "int64", + "maximum": 25, + "minimum": 1, + "type": "integer" + } + }, + { + "explode": false, + "in": "query", + "name": "refresh", + "schema": { + "default": false, + "type": "boolean" } } ], @@ -108650,7 +109316,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminSettingReadOutputBody" + "$ref": "#/components/schemas/AdminDashboardDownloadsStats" } } }, @@ -108752,34 +109418,18 @@ "bearerAuth": [] } ], - "summary": "Read one visible stored setting; protected, missing and empty values return not found.", + "summary": "Read account-level managed-download counts and bounded top users, retaining existing lifecycle definitions.", "tags": [ - "admin-settings" + "admin-observability" ], "x-silo-class": "acting_admin", "x-silo-service-backed": true - }, - "put": { - "description": "Turning auth.local_password_login off while no enabled break-glass admin can sign in with a password is 409 break_glass_required.", - "operationId": "updateAdminSetting", + } + }, + "/api/v2/admin/stats/playback-activity": { + "get": { + "operationId": "getAdminDashboardPlaybackActivity", "parameters": [ - { - "description": "The resource's current ETag, or \"*\" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag.", - "in": "header", - "name": "If-Match", - "required": true, - "schema": { - "type": "string" - } - }, - { - "description": "Optional second precondition, evaluated after If-Match succeeds: \"*\" or any tag matching the current representation is 412 precondition_failed with the current ETag.", - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } - }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -108803,32 +109453,35 @@ } }, { - "in": "path", - "name": "key", - "required": true, + "explode": false, + "in": "query", + "name": "hours", "schema": { - "maxLength": 256, - "minLength": 1, - "type": "string" + "default": 24, + "format": "int64", + "maximum": 744, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Invalidate the cached aggregate before reading.", + "explode": false, + "in": "query", + "name": "refresh", + "schema": { + "default": false, + "description": "Invalidate the cached aggregate before reading.", + "type": "boolean" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminSettingUpdateInputBody" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminSettingValue" + "$ref": "#/components/schemas/AdminDashboardPlaybackActivity" } } }, @@ -108884,64 +109537,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Conflict" - }, - "412": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Failed", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -108952,16 +109547,6 @@ }, "description": "Unprocessable Entity" }, - "428": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Required" - }, "429": { "content": { "application/problem+json": { @@ -108998,20 +109583,17 @@ "bearerAuth": [] } ], - "summary": "Validate and replace one setting with the established single-key validation rules and transaction guard.", + "summary": "Read playback method buckets, finalized-session reliability, and rolling active profiles.", "tags": [ - "admin-settings" + "admin-observability" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-guarded": true, - "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/stats": { + "/api/v2/admin/stats/timeseries": { "get": { - "operationId": "getAdminDashboardStats", + "operationId": "getAdminDashboardTimeseries", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -109036,11 +109618,25 @@ } }, { + "explode": false, + "in": "query", + "name": "hours", + "schema": { + "default": 24, + "format": "int64", + "maximum": 744, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Invalidate the cached aggregate before reading.", "explode": false, "in": "query", "name": "refresh", "schema": { "default": false, + "description": "Invalidate the cached aggregate before reading.", "type": "boolean" } } @@ -109050,7 +109646,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminDashboardStats" + "$ref": "#/components/schemas/AdminDashboardTimeseries" } } }, @@ -109152,7 +109748,7 @@ "bearerAuth": [] } ], - "summary": "Read overall dashboard statistics using the existing provider cache and fallback.", + "summary": "Read sampled stream counts and egress with the existing database window and bucket resolution.", "tags": [ "admin-observability" ], @@ -109160,9 +109756,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/stats/downloads": { + "/api/v2/admin/stats/top-activity": { "get": { - "operationId": "getAdminDashboardDownloadsStats", + "operationId": "getAdminDashboardTopActivity", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -109186,6 +109782,18 @@ "type": "string" } }, + { + "explode": false, + "in": "query", + "name": "days", + "schema": { + "default": 7, + "format": "int64", + "maximum": 30, + "minimum": 1, + "type": "integer" + } + }, { "explode": false, "in": "query", @@ -109213,7 +109821,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminDashboardDownloadsStats" + "$ref": "#/components/schemas/AdminDashboardTopActivity" } } }, @@ -109315,7 +109923,7 @@ "bearerAuth": [] } ], - "summary": "Read account-level managed-download counts and bounded top users, retaining existing lifecycle definitions.", + "summary": "Read bounded title and household-profile leaderboards from existing watch history aggregates.", "tags": [ "admin-observability" ], @@ -109323,9 +109931,9 @@ "x-silo-service-backed": true } }, - "/api/v2/admin/stats/playback-activity": { - "get": { - "operationId": "getAdminDashboardPlaybackActivity", + "/api/v2/admin/storage-transitions": { + "post": { + "operationId": "createAdminStorageTransition", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -109348,41 +109956,40 @@ ], "type": "string" } - }, - { - "explode": false, - "in": "query", - "name": "hours", - "schema": { - "default": 24, - "format": "int64", - "maximum": 744, - "minimum": 1, - "type": "integer" - } - }, - { - "description": "Invalidate the cached aggregate before reading.", - "explode": false, - "in": "query", - "name": "refresh", - "schema": { - "default": false, - "description": "Invalidate the cached aggregate before reading.", - "type": "boolean" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminStorageTransitionRequest" + } + } + }, + "required": true + }, "responses": { - "200": { + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminDashboardPlaybackActivity" + "$ref": "#/components/schemas/AdminStorageTransitionAccepted" } } }, - "description": "OK" + "description": "Accepted", + "headers": { + "Location": { + "schema": { + "type": "string" + } + }, + "Retry-After": { + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -109434,6 +110041,46 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -109480,18 +110127,28 @@ "bearerAuth": [] } ], - "summary": "Read playback method buckets, finalized-session reliability, and rolling active profiles.", + "summary": "Queue a verified managed storage transition. The old location is retained and the committed target takes effect after restart.", "tags": [ - "admin-observability" + "admin-settings" ], "x-silo-class": "acting_admin", + "x-silo-demo-restricted": true, + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/stats/timeseries": { + "/api/v2/admin/storage-transitions/capabilities": { "get": { - "operationId": "getAdminDashboardTimeseries", + "operationId": "getAdminStorageTransitionCapabilities", "parameters": [ + { + "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", + "in": "header", + "name": "If-Match", + "schema": { + "type": "string" + } + }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -109515,26 +110172,10 @@ } }, { - "explode": false, - "in": "query", - "name": "hours", - "schema": { - "default": 24, - "format": "int64", - "maximum": 744, - "minimum": 1, - "type": "integer" - } - }, - { - "description": "Invalidate the cached aggregate before reading.", - "explode": false, - "in": "query", - "name": "refresh", + "in": "header", + "name": "If-None-Match", "schema": { - "default": false, - "description": "Invalidate the cached aggregate before reading.", - "type": "boolean" + "type": "string" } } ], @@ -109543,11 +110184,35 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminDashboardTimeseries" + "$ref": "#/components/schemas/AdminStorageTransitionCapabilities" } } }, - "description": "OK" + "description": "OK", + "headers": { + "Cache-Control": { + "schema": { + "type": "string" + } + }, + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "The representation named by If-None-Match is current; no body.", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -109599,6 +110264,24 @@ }, "description": "Not Acceptable" }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, "422": { "content": { "application/problem+json": { @@ -109645,17 +110328,17 @@ "bearerAuth": [] } ], - "summary": "Read sampled stream counts and egress with the existing database window and bucket resolution.", + "summary": "Discover managed storage transition support in this build.", "tags": [ - "admin-observability" + "admin-settings" ], "x-silo-class": "acting_admin", - "x-silo-service-backed": true + "x-silo-conditional": true } }, - "/api/v2/admin/stats/top-activity": { + "/api/v2/admin/storage-transitions/source-health": { "get": { - "operationId": "getAdminDashboardTopActivity", + "operationId": "getAdminStorageTransitionSourceHealth", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -109682,33 +110365,9 @@ { "explode": false, "in": "query", - "name": "days", - "schema": { - "default": 7, - "format": "int64", - "maximum": 30, - "minimum": 1, - "type": "integer" - } - }, - { - "explode": false, - "in": "query", - "name": "limit", - "schema": { - "default": 10, - "format": "int64", - "maximum": 25, - "minimum": 1, - "type": "integer" - } - }, - { - "explode": false, - "in": "query", - "name": "refresh", + "name": "probe", "schema": { - "default": false, + "default": true, "type": "boolean" } } @@ -109718,7 +110377,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminDashboardTopActivity" + "$ref": "#/components/schemas/AdminStorageTransitionSourceHealth" } } }, @@ -109820,17 +110479,17 @@ "bearerAuth": [] } ], - "summary": "Read bounded title and household-profile leaderboards from existing watch history aggregates.", + "summary": "Check whether the currently configured S3 source is reachable before choosing a storage-transition policy.", "tags": [ - "admin-observability" + "admin-settings" ], "x-silo-class": "acting_admin", "x-silo-service-backed": true } }, - "/api/v2/admin/storage-transitions": { - "post": { - "operationId": "createAdminStorageTransition", + "/api/v2/admin/stream-telemetry/parity": { + "get": { + "operationId": "getAdminStreamTelemetryParity", "parameters": [ { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", @@ -109855,38 +110514,16 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminStorageTransitionRequest" - } - } - }, - "required": true - }, "responses": { - "202": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminStorageTransitionAccepted" + "$ref": "#/components/schemas/AdminTelemetryParity" } } }, - "description": "Accepted", - "headers": { - "Location": { - "schema": { - "type": "string" - } - }, - "Retry-After": { - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "400": { "content": { @@ -109938,46 +110575,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Conflict" - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -110024,28 +110621,18 @@ "bearerAuth": [] } ], - "summary": "Queue a verified managed storage transition. The old location is retained and the committed target takes effect after restart.", + "summary": "Compare cached telemetry and bounded legacy session reads. Disabled, missing, incomplete or capped sources are not evidence of parity or cutover readiness.", "tags": [ - "admin-settings" + "admin-observability" ], "x-silo-class": "acting_admin", - "x-silo-demo-restricted": true, - "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/admin/storage-transitions/capabilities": { + "/api/v2/admin/subtitle-providers": { "get": { - "operationId": "getAdminStorageTransitionCapabilities", + "operationId": "listAdminSubtitleProviders", "parameters": [ - { - "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", - "in": "header", - "name": "If-Match", - "schema": { - "type": "string" - } - }, { "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", "in": "header", @@ -110067,13 +110654,6 @@ ], "type": "string" } - }, - { - "in": "header", - "name": "If-None-Match", - "schema": { - "type": "string" - } } ], "responses": { @@ -110081,488 +110661,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdminStorageTransitionCapabilities" + "$ref": "#/components/schemas/AdminSubtitleProvidersOutputBody" } } }, - "description": "OK", - "headers": { - "Cache-Control": { - "schema": { - "type": "string" - } - }, - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "The representation named by If-None-Match is current; no body.", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, - "400": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Bad Request" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unauthorized" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Forbidden" - }, - "404": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Not Found" - }, - "406": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Not Acceptable" - }, - "412": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Failed", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, - "422": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unprocessable Entity" - }, - "429": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Too Many Requests" - }, - "500": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Internal Server Error" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Service Unavailable" - } - }, - "security": [ - { - "bearerAuth": [] - } - ], - "summary": "Discover managed storage transition support in this build.", - "tags": [ - "admin-settings" - ], - "x-silo-class": "acting_admin", - "x-silo-conditional": true - } - }, - "/api/v2/admin/storage-transitions/source-health": { - "get": { - "operationId": "getAdminStorageTransitionSourceHealth", - "parameters": [ - { - "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", - "in": "header", - "name": "X-Profile-Id", - "schema": { - "examples": [ - "1" - ], - "type": "string" - } - }, - { - "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", - "in": "header", - "name": "X-Profile-Token", - "schema": { - "examples": [ - "pvt_5f3a9c1e7b2d4e8fa0c6" - ], - "type": "string" - } - }, - { - "explode": false, - "in": "query", - "name": "probe", - "schema": { - "default": true, - "type": "boolean" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminStorageTransitionSourceHealth" - } - } - }, - "description": "OK" - }, - "400": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Bad Request" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unauthorized" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Forbidden" - }, - "404": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Not Found" - }, - "406": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Not Acceptable" - }, - "422": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unprocessable Entity" - }, - "429": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Too Many Requests" - }, - "500": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Internal Server Error" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Service Unavailable" - } - }, - "security": [ - { - "bearerAuth": [] - } - ], - "summary": "Check whether the currently configured S3 source is reachable before choosing a storage-transition policy.", - "tags": [ - "admin-settings" - ], - "x-silo-class": "acting_admin", - "x-silo-service-backed": true - } - }, - "/api/v2/admin/stream-telemetry/parity": { - "get": { - "operationId": "getAdminStreamTelemetryParity", - "parameters": [ - { - "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", - "in": "header", - "name": "X-Profile-Id", - "schema": { - "examples": [ - "1" - ], - "type": "string" - } - }, - { - "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", - "in": "header", - "name": "X-Profile-Token", - "schema": { - "examples": [ - "pvt_5f3a9c1e7b2d4e8fa0c6" - ], - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminTelemetryParity" - } - } - }, - "description": "OK" - }, - "400": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Bad Request" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unauthorized" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Forbidden" - }, - "404": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Not Found" - }, - "406": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Not Acceptable" - }, - "422": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unprocessable Entity" - }, - "429": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Too Many Requests" - }, - "500": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Internal Server Error" - }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Service Unavailable" - } - }, - "security": [ - { - "bearerAuth": [] - } - ], - "summary": "Compare cached telemetry and bounded legacy session reads. Disabled, missing, incomplete or capped sources are not evidence of parity or cutover readiness.", - "tags": [ - "admin-observability" - ], - "x-silo-class": "acting_admin", - "x-silo-service-backed": true - } - }, - "/api/v2/admin/subtitle-providers": { - "get": { - "operationId": "listAdminSubtitleProviders", - "parameters": [ - { - "description": "Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted.", - "in": "header", - "name": "X-Profile-Id", - "schema": { - "examples": [ - "1" - ], - "type": "string" - } - }, - { - "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", - "in": "header", - "name": "X-Profile-Token", - "schema": { - "examples": [ - "pvt_5f3a9c1e7b2d4e8fa0c6" - ], - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdminSubtitleProvidersOutputBody" - } - } - }, - "description": "OK" + "description": "OK" }, "400": { "content": { @@ -128263,6 +128366,31 @@ "type": "string" } }, + { + "description": "Restrict to several libraries, one library_ids parameter per id; combines with library_id. Libraries the viewer cannot see are dropped, and a scope left with none is empty. Not accepted with source=section", + "explode": true, + "in": "query", + "name": "library_ids", + "schema": { + "description": "Restrict to several libraries, one library_ids parameter per id; combines with library_id. Libraries the viewer cannot see are dropped, and a scope left with none is empty. Not accepted with source=section", + "examples": [ + [ + "1", + "2" + ] + ], + "items": { + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array" + } + }, { "explode": false, "in": "query", @@ -128505,6 +128633,31 @@ "type": "string" } }, + { + "description": "Restrict to several libraries, one library_ids parameter per id; combines with library_id. Libraries the viewer cannot see are dropped, and a scope left with none is empty. Not accepted with source=section", + "explode": true, + "in": "query", + "name": "library_ids", + "schema": { + "description": "Restrict to several libraries, one library_ids parameter per id; combines with library_id. Libraries the viewer cannot see are dropped, and a scope left with none is empty. Not accepted with source=section", + "examples": [ + [ + "1", + "2" + ] + ], + "items": { + "description": "Opaque identifier", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array" + } + }, { "explode": false, "in": "query", @@ -128576,12 +128729,12 @@ } }, { - "description": "Case-insensitive prefix", + "description": "Case-insensitive search text; see matches and values for how each answers it", "explode": false, "in": "query", "name": "q", "schema": { - "description": "Case-insensitive prefix", + "description": "Case-insensitive search text; see matches and values for how each answers it", "examples": [ "ste" ], @@ -128589,13 +128742,13 @@ } }, { - "description": "Most matches to return; default 20, maximum 100", + "description": "Most values to return in matches and in values; default 20, maximum 100", "explode": false, "in": "query", "name": "limit", "schema": { "default": 20, - "description": "Most matches to return; default 20, maximum 100", + "description": "Most values to return in matches and in values; default 20, maximum 100", "format": "int64", "maximum": 100, "minimum": 1, @@ -128710,7 +128863,7 @@ "bearerAuth": [] } ], - "summary": "Prefix typeahead over one facet of a scope.", + "summary": "Typeahead over one facet of a scope, with title counts.", "tags": [ "catalog" ], @@ -130310,39 +130463,291 @@ } }, { - "description": "Content id of the item, season, or episode", + "description": "Content id of the item, season, or episode", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Content id of the item, season, or episode", + "examples": [ + "movie:heat-1995" + ], + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TranslateDescription" + } + } + }, + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataTranslationJob" + } + } + }, + "description": "Accepted" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "406": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Acceptable" + }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Queue a translation of the item's descriptions into the language the detail document reported missing; answers 202 with the job.", + "tags": [ + "catalog" + ], + "x-silo-class": "profile_scoped", + "x-silo-retry-safety": "coalescing", + "x-silo-service-backed": true + } + }, + "/api/v2/catalog/items/{id}/versions": { + "get": { + "operationId": "listCatalogItemVersions", + "parameters": [ + { + "description": "The household profile acting for this request; it must belong to the authenticated account.", + "in": "header", + "name": "X-Profile-Id", + "required": true, + "schema": { + "examples": [ + "1" + ], + "type": "string" + } + }, + { + "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", + "in": "header", + "name": "X-Profile-Token", + "schema": { + "examples": [ + "pvt_5f3a9c1e7b2d4e8fa0c6" + ], + "type": "string" + } + }, + { + "description": "The stable device identifier used to resolve device-scoped playback preferences; absent resolves the profile's preferences", + "in": "header", + "name": "X-Silo-Device-Id", + "schema": { + "description": "The stable device identifier used to resolve device-scoped playback preferences; absent resolves the profile's preferences", + "examples": [ + "tv-1" + ], + "maxLength": 128, + "type": "string" + } + }, + { + "description": "Content id", "in": "path", "name": "id", "required": true, "schema": { - "description": "Content id of the item, season, or episode", + "description": "Content id", "examples": [ "movie:heat-1995" ], "type": "string" } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TranslateDescription" - } + }, + { + "explode": false, + "in": "query", + "name": "image_size", + "schema": { + "enum": [ + "small", + "medium", + "large", + "original" + ], + "type": "string" } }, - "required": true - }, + { + "description": "The library the item is being viewed in; picks its presentation when the item is in several", + "explode": false, + "in": "query", + "name": "library_id", + "schema": { + "description": "The library the item is being viewed in; picks its presentation when the item is in several", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + } + }, + { + "description": "The version the viewer selected; affects the effective playback answer", + "explode": false, + "in": "query", + "name": "file_id", + "schema": { + "description": "The version the viewer selected; affects the effective playback answer", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + } + } + ], "responses": { - "202": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MetadataTranslationJob" + "$ref": "#/components/schemas/FileVersionCollection" } } }, - "description": "Accepted" + "description": "OK" }, "400": { "content": { @@ -130394,46 +130799,6 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Conflict" - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, "422": { "content": { "application/problem+json": { @@ -130480,18 +130845,17 @@ "bearerAuth": [] } ], - "summary": "Queue a translation of the item's descriptions into the language the detail document reported missing; answers 202 with the job.", + "summary": "The playable file versions of an item.", "tags": [ "catalog" ], "x-silo-class": "profile_scoped", - "x-silo-retry-safety": "coalescing", "x-silo-service-backed": true } }, - "/api/v2/catalog/items/{id}/versions": { + "/api/v2/catalog/people": { "get": { - "operationId": "listCatalogItemVersions", + "operationId": "listPeople", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -130517,70 +130881,47 @@ } }, { - "description": "The stable device identifier used to resolve device-scoped playback preferences; absent resolves the profile's preferences", - "in": "header", - "name": "X-Silo-Device-Id", - "schema": { - "description": "The stable device identifier used to resolve device-scoped playback preferences; absent resolves the profile's preferences", - "examples": [ - "tv-1" - ], - "maxLength": 128, - "type": "string" - } - }, - { - "description": "Content id", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Content id", - "examples": [ - "movie:heat-1995" - ], - "type": "string" - } - }, - { + "description": "Name prefix or fragment; empty lists the first people", "explode": false, "in": "query", - "name": "image_size", + "name": "q", "schema": { - "enum": [ - "small", - "medium", - "large", - "original" - ], + "description": "Name prefix or fragment; empty lists the first people", + "maxLength": 200, "type": "string" } }, { - "description": "The library the item is being viewed in; picks its presentation when the item is in several", + "description": "Most people to answer", "explode": false, "in": "query", - "name": "library_id", + "name": "limit", "schema": { - "description": "The library the item is being viewed in; picks its presentation when the item is in several", - "examples": [ - "1" - ], - "minLength": 1, - "type": "string" + "default": 20, + "description": "Most people to answer", + "format": "int64", + "maximum": 100, + "minimum": 1, + "type": "integer" } }, { - "description": "The version the viewer selected; affects the effective playback answer", + "description": "Restrict people to accessible credits in this media scope; omitted searches all media scopes. video covers movies and series; video_with_episodes adds episodes (check getCatalogSearchCapabilities.video_with_episodes_scope first)", "explode": false, "in": "query", - "name": "file_id", + "name": "media_scope", "schema": { - "description": "The version the viewer selected; affects the effective playback answer", - "examples": [ - "1" + "description": "Restrict people to accessible credits in this media scope; omitted searches all media scopes. video covers movies and series; video_with_episodes adds episodes (check getCatalogSearchCapabilities.video_with_episodes_scope first)", + "enum": [ + "video", + "video_with_episodes", + "movie", + "series", + "episode", + "audiobook", + "ebook", + "manga" ], - "minLength": 1, "type": "string" } } @@ -130590,7 +130931,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FileVersionCollection" + "$ref": "#/components/schemas/PersonCollection" } } }, @@ -130692,7 +131033,7 @@ "bearerAuth": [] } ], - "summary": "The playable file versions of an item.", + "summary": "Search people by name, with exact matches first and optional media scope.", "tags": [ "catalog" ], @@ -130700,9 +131041,9 @@ "x-silo-service-backed": true } }, - "/api/v2/catalog/people": { + "/api/v2/catalog/people/{id}": { "get": { - "operationId": "listPeople", + "operationId": "getPerson", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -130728,48 +131069,28 @@ } }, { - "description": "Name prefix or fragment; empty lists the first people", - "explode": false, - "in": "query", - "name": "q", + "description": "Person identifier", + "in": "path", + "name": "id", + "required": true, "schema": { - "description": "Name prefix or fragment; empty lists the first people", - "maxLength": 200, + "description": "Person identifier", + "examples": [ + "7" + ], + "minLength": 1, "type": "string" } }, { - "description": "Most people to answer", - "explode": false, - "in": "query", - "name": "limit", - "schema": { - "default": 20, - "description": "Most people to answer", - "format": "int64", - "maximum": 100, - "minimum": 1, - "type": "integer" - } - }, - { - "description": "Restrict people to accessible credits in this media scope; omitted searches all media scopes. video covers movies and series; video_with_episodes adds episodes (check getCatalogSearchCapabilities.video_with_episodes_scope first)", + "description": "Marks a speculative read, such as warming a cache for a cast list. The read does not queue a provider refresh.", "explode": false, "in": "query", - "name": "media_scope", + "name": "prefetch", "schema": { - "description": "Restrict people to accessible credits in this media scope; omitted searches all media scopes. video covers movies and series; video_with_episodes adds episodes (check getCatalogSearchCapabilities.video_with_episodes_scope first)", - "enum": [ - "video", - "video_with_episodes", - "movie", - "series", - "episode", - "audiobook", - "ebook", - "manga" - ], - "type": "string" + "default": false, + "description": "Marks a speculative read, such as warming a cache for a cast list. The read does not queue a provider refresh.", + "type": "boolean" } } ], @@ -130778,7 +131099,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PersonCollection" + "$ref": "#/components/schemas/Person" } } }, @@ -130880,7 +131201,7 @@ "bearerAuth": [] } ], - "summary": "Search people by name, with exact matches first and optional media scope.", + "summary": "One person; viewing queues a provider refresh when one is due, unless the read is a prefetch.", "tags": [ "catalog" ], @@ -130888,9 +131209,9 @@ "x-silo-service-backed": true } }, - "/api/v2/catalog/people/{id}": { - "get": { - "operationId": "getPerson", + "/api/v2/catalog/people/{id}/refresh": { + "post": { + "operationId": "refreshPerson", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -130928,29 +131249,18 @@ "minLength": 1, "type": "string" } - }, - { - "description": "Marks a speculative read, such as warming a cache for a cast list. The read does not queue a provider refresh.", - "explode": false, - "in": "query", - "name": "prefetch", - "schema": { - "default": false, - "description": "Marks a speculative read, such as warming a cache for a cast list. The read does not queue a provider refresh.", - "type": "boolean" - } } ], "responses": { - "200": { + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Person" + "$ref": "#/components/schemas/PersonRefresh" } } }, - "description": "OK" + "description": "Accepted" }, "400": { "content": { @@ -131048,17 +131358,18 @@ "bearerAuth": [] } ], - "summary": "One person; viewing queues a provider refresh when one is due, unless the read is a prefetch.", + "summary": "Queue a provider refresh of the person; answers 202 once queued.", "tags": [ "catalog" ], "x-silo-class": "profile_scoped", + "x-silo-retry-safety": "non_retryable", "x-silo-service-backed": true } }, - "/api/v2/catalog/people/{id}/refresh": { + "/api/v2/catalog/query": { "post": { - "operationId": "refreshPerson", + "operationId": "queryCatalogItems", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -131084,30 +131395,40 @@ } }, { - "description": "Person identifier", - "in": "path", - "name": "id", - "required": true, + "explode": false, + "in": "query", + "name": "image_size", "schema": { - "description": "Person identifier", - "examples": [ - "7" + "enum": [ + "small", + "medium", + "large", + "original" ], - "minLength": 1, "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CatalogQuery" + } + } + }, + "required": true + }, "responses": { - "202": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PersonRefresh" + "$ref": "#/components/schemas/CatalogBrowseCollection" } } }, - "description": "Accepted" + "description": "OK" }, "400": { "content": { @@ -131159,6 +131480,36 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -131205,19 +131556,27 @@ "bearerAuth": [] } ], - "summary": "Queue a provider refresh of the person; answers 202 once queued.", + "summary": "Page the catalog by a JSON rule-group query; the body form of the browse.", "tags": [ "catalog" ], "x-silo-class": "profile_scoped", - "x-silo-retry-safety": "non_retryable", + "x-silo-retry-safety": "natural_idempotent", "x-silo-service-backed": true } }, - "/api/v2/catalog/query": { - "post": { - "operationId": "queryCatalogItems", + "/api/v2/catalog/search/capabilities": { + "get": { + "operationId": "getCatalogSearchCapabilities", "parameters": [ + { + "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", + "in": "header", + "name": "If-Match", + "schema": { + "type": "string" + } + }, { "description": "The household profile acting for this request; it must belong to the authenticated account.", "in": "header", @@ -131242,40 +131601,47 @@ } }, { - "explode": false, - "in": "query", - "name": "image_size", + "in": "header", + "name": "If-None-Match", "schema": { - "enum": [ - "small", - "medium", - "large", - "original" - ], "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CatalogQuery" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CatalogBrowseCollection" + "$ref": "#/components/schemas/CatalogSearchCapabilities" } } }, - "description": "OK" + "description": "OK", + "headers": { + "Cache-Control": { + "schema": { + "type": "string" + } + }, + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "The representation named by If-None-Match is current; no body.", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -131327,17 +131693,7 @@ }, "description": "Not Acceptable" }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "413": { + "412": { "content": { "application/problem+json": { "schema": { @@ -131345,17 +131701,15 @@ } } }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", "schema": { - "$ref": "#/components/schemas/Problem" + "type": "string" } } - }, - "description": "Unsupported Media Type" + } }, "422": { "content": { @@ -131403,27 +131757,19 @@ "bearerAuth": [] } ], - "summary": "Page the catalog by a JSON rule-group query; the body form of the browse.", + "summary": "Search continuation provider, result window, and session lifetime.", "tags": [ "catalog" ], "x-silo-class": "profile_scoped", - "x-silo-retry-safety": "natural_idempotent", + "x-silo-conditional": true, "x-silo-service-backed": true } }, - "/api/v2/catalog/search/capabilities": { + "/api/v2/catalog/series/{id}/seasons": { "get": { - "operationId": "getCatalogSearchCapabilities", + "operationId": "listSeriesSeasons", "parameters": [ - { - "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", - "in": "header", - "name": "If-Match", - "schema": { - "type": "string" - } - }, { "description": "The household profile acting for this request; it must belong to the authenticated account.", "in": "header", @@ -131448,11 +131794,56 @@ } }, { - "in": "header", - "name": "If-None-Match", + "description": "Series content id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Series content id", + "examples": [ + "series:severance" + ], + "type": "string" + } + }, + { + "explode": false, + "in": "query", + "name": "image_size", "schema": { + "enum": [ + "small", + "medium", + "large", + "original" + ], "type": "string" } + }, + { + "description": "The library the series is being viewed in", + "explode": false, + "in": "query", + "name": "library_id", + "schema": { + "description": "The library the series is being viewed in", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + } + }, + { + "description": "Include poster URLs and thumbhashes; false skips poster preparation", + "explode": false, + "in": "query", + "name": "include_artwork", + "schema": { + "default": true, + "description": "Include poster URLs and thumbhashes; false skips poster preparation", + "type": "boolean" + } } ], "responses": { @@ -131460,35 +131851,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CatalogSearchCapabilities" + "$ref": "#/components/schemas/SeasonCollection" } } }, - "description": "OK", - "headers": { - "Cache-Control": { - "schema": { - "type": "string" - } - }, - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "The representation named by If-None-Match is current; no body.", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "400": { "content": { @@ -131540,24 +131907,6 @@ }, "description": "Not Acceptable" }, - "412": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Failed", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, "422": { "content": { "application/problem+json": { @@ -131604,18 +131953,17 @@ "bearerAuth": [] } ], - "summary": "Search continuation provider, result window, and session lifetime.", + "summary": "The seasons of a series with the viewer's rollups.", "tags": [ "catalog" ], "x-silo-class": "profile_scoped", - "x-silo-conditional": true, "x-silo-service-backed": true } }, - "/api/v2/catalog/series/{id}/seasons": { + "/api/v2/catalog/series/{id}/seasons/{num}": { "get": { - "operationId": "listSeriesSeasons", + "operationId": "getSeriesSeason", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -131682,14 +132030,18 @@ } }, { - "description": "Include poster URLs and thumbhashes; false skips poster preparation", - "explode": false, - "in": "query", - "name": "include_artwork", + "description": "Season number; 0 is specials", + "in": "path", + "name": "num", + "required": true, "schema": { - "default": true, - "description": "Include poster URLs and thumbhashes; false skips poster preparation", - "type": "boolean" + "description": "Season number; 0 is specials", + "examples": [ + 1 + ], + "format": "int64", + "minimum": 0, + "type": "integer" } } ], @@ -131698,7 +132050,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SeasonCollection" + "$ref": "#/components/schemas/Season" } } }, @@ -131800,7 +132152,7 @@ "bearerAuth": [] } ], - "summary": "The seasons of a series with the viewer's rollups.", + "summary": "One season of a series by number.", "tags": [ "catalog" ], @@ -131808,9 +132160,9 @@ "x-silo-service-backed": true } }, - "/api/v2/catalog/series/{id}/seasons/{num}": { + "/api/v2/catalog/series/{id}/seasons/{num}/episodes": { "get": { - "operationId": "getSeriesSeason", + "operationId": "listSeasonEpisodes", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -131897,7 +132249,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Season" + "$ref": "#/components/schemas/EpisodeCollection" } } }, @@ -131999,7 +132351,7 @@ "bearerAuth": [] } ], - "summary": "One season of a series by number.", + "summary": "The episodes of one season of a series by number.", "tags": [ "catalog" ], @@ -132007,10 +132359,18 @@ "x-silo-service-backed": true } }, - "/api/v2/catalog/series/{id}/seasons/{num}/episodes": { + "/api/v2/catalog/themes/capabilities": { "get": { - "operationId": "listSeasonEpisodes", + "operationId": "getThemeSongsCapability", "parameters": [ + { + "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", + "in": "header", + "name": "If-Match", + "schema": { + "type": "string" + } + }, { "description": "The household profile acting for this request; it must belong to the authenticated account.", "in": "header", @@ -132035,60 +132395,11 @@ } }, { - "description": "Series content id", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Series content id", - "examples": [ - "series:severance" - ], - "type": "string" - } - }, - { - "explode": false, - "in": "query", - "name": "image_size", - "schema": { - "enum": [ - "small", - "medium", - "large", - "original" - ], - "type": "string" - } - }, - { - "description": "The library the series is being viewed in", - "explode": false, - "in": "query", - "name": "library_id", + "in": "header", + "name": "If-None-Match", "schema": { - "description": "The library the series is being viewed in", - "examples": [ - "1" - ], - "minLength": 1, "type": "string" } - }, - { - "description": "Season number; 0 is specials", - "in": "path", - "name": "num", - "required": true, - "schema": { - "description": "Season number; 0 is specials", - "examples": [ - 1 - ], - "format": "int64", - "minimum": 0, - "type": "integer" - } } ], "responses": { @@ -132096,11 +132407,35 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EpisodeCollection" + "$ref": "#/components/schemas/ThemeSongsCapability" } } }, - "description": "OK" + "description": "OK", + "headers": { + "Cache-Control": { + "schema": { + "type": "string" + } + }, + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, + "304": { + "description": "The representation named by If-None-Match is current; no body.", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } }, "400": { "content": { @@ -132152,6 +132487,24 @@ }, "description": "Not Acceptable" }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Precondition Failed", + "headers": { + "ETag": { + "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", + "schema": { + "type": "string" + } + } + } + }, "422": { "content": { "application/problem+json": { @@ -132198,26 +132551,18 @@ "bearerAuth": [] } ], - "summary": "The episodes of one season of a series by number.", + "summary": "Local theme audio support and delivery limitations.", "tags": [ "catalog" ], "x-silo-class": "profile_scoped", - "x-silo-service-backed": true + "x-silo-conditional": true } }, - "/api/v2/catalog/themes/capabilities": { + "/api/v2/catalog/works/{work_id}": { "get": { - "operationId": "getThemeSongsCapability", + "operationId": "getLiteraryWork", "parameters": [ - { - "description": "Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed.", - "in": "header", - "name": "If-Match", - "schema": { - "type": "string" - } - }, { "description": "The household profile acting for this request; it must belong to the authenticated account.", "in": "header", @@ -132242,9 +132587,15 @@ } }, { - "in": "header", - "name": "If-None-Match", + "description": "Work identifier", + "in": "path", + "name": "work_id", + "required": true, "schema": { + "description": "Work identifier", + "examples": [ + "work:dune-1965" + ], "type": "string" } } @@ -132254,35 +132605,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ThemeSongsCapability" + "$ref": "#/components/schemas/LiteraryWork" } } }, - "description": "OK", - "headers": { - "Cache-Control": { - "schema": { - "type": "string" - } - }, - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, - "304": { - "description": "The representation named by If-None-Match is current; no body.", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } + "description": "OK" }, "400": { "content": { @@ -132334,24 +132661,6 @@ }, "description": "Not Acceptable" }, - "412": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Precondition Failed", - "headers": { - "ETag": { - "description": "The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read.", - "schema": { - "type": "string" - } - } - } - }, "422": { "content": { "application/problem+json": { @@ -132398,17 +132707,17 @@ "bearerAuth": [] } ], - "summary": "Local theme audio support and delivery limitations.", + "summary": "A literary work with the ebook and audiobook editions the viewer can see and their progress.", "tags": [ "catalog" ], "x-silo-class": "profile_scoped", - "x-silo-conditional": true + "x-silo-service-backed": true } }, - "/api/v2/catalog/works/{work_id}": { + "/api/v2/collections": { "get": { - "operationId": "getLiteraryWork", + "operationId": "listCollections", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -132434,14 +132743,14 @@ } }, { - "description": "Work identifier", - "in": "path", - "name": "work_id", - "required": true, + "description": "A title's content id. Each of the acting profile's own manual collections then carries contains. Accepted when getCollectionCapabilities reports contains_item", + "explode": false, + "in": "query", + "name": "contains_item", "schema": { - "description": "Work identifier", + "description": "A title's content id. Each of the acting profile's own manual collections then carries contains. Accepted when getCollectionCapabilities reports contains_item", "examples": [ - "work:dune-1965" + "01J9Z8C3W4R5T6Y7U8I9O0P1Q5" ], "type": "string" } @@ -132452,7 +132761,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LiteraryWork" + "$ref": "#/components/schemas/PersonalCollectionCollection" } } }, @@ -132554,17 +132863,15 @@ "bearerAuth": [] } ], - "summary": "A literary work with the ebook and audiobook editions the viewer can see and their progress.", + "summary": "List the acting profile's own collections in its order, then the collections other profiles on the login share, grouped by creator and each in its creator's order.", "tags": [ - "catalog" + "collections" ], "x-silo-class": "profile_scoped", "x-silo-service-backed": true - } - }, - "/api/v2/collections": { - "get": { - "operationId": "listCollections", + }, + "post": { + "operationId": "createCollection", "parameters": [ { "description": "The household profile acting for this request; it must belong to the authenticated account.", @@ -132590,16 +132897,34 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonalCollectionCreate" + } + } + }, + "required": true + }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PersonalCollectionCollection" + "$ref": "#/components/schemas/PersonalCollection" } } }, - "description": "OK" + "description": "Created", + "headers": { + "Location": { + "schema": { + "description": "The created collection's resource path", + "type": "string" + } + } + } }, "400": { "content": { @@ -132651,6 +132976,36 @@ }, "description": "Not Acceptable" }, + "408": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Timeout" + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unsupported Media Type" + }, "422": { "content": { "application/problem+json": { @@ -132681,186 +133036,7 @@ }, "description": "Internal Server Error" }, - "503": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Service Unavailable" - } - }, - "security": [ - { - "bearerAuth": [] - } - ], - "summary": "List the collections the acting profile owns or may see, with the account's collection groups.", - "tags": [ - "collections" - ], - "x-silo-class": "profile_scoped", - "x-silo-service-backed": true - }, - "post": { - "operationId": "createCollection", - "parameters": [ - { - "description": "The household profile acting for this request; it must belong to the authenticated account.", - "in": "header", - "name": "X-Profile-Id", - "required": true, - "schema": { - "examples": [ - "1" - ], - "type": "string" - } - }, - { - "description": "Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked", - "in": "header", - "name": "X-Profile-Token", - "schema": { - "examples": [ - "pvt_5f3a9c1e7b2d4e8fa0c6" - ], - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PersonalCollectionCreate" - } - } - }, - "required": true - }, - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PersonalCollection" - } - } - }, - "description": "Created", - "headers": { - "Location": { - "schema": { - "description": "The created collection's resource path", - "type": "string" - } - } - } - }, - "400": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Bad Request" - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unauthorized" - }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Forbidden" - }, - "404": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Not Found" - }, - "406": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Not Acceptable" - }, - "408": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Timeout" - }, - "413": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Request Entity Too Large" - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unsupported Media Type" - }, - "422": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Unprocessable Entity" - }, - "429": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - }, - "description": "Too Many Requests" - }, - "500": { + "501": { "content": { "application/problem+json": { "schema": { @@ -132868,7 +133044,7 @@ } } }, - "description": "Internal Server Error" + "description": "Not Implemented" }, "503": { "content": { @@ -133264,6 +133440,16 @@ }, "description": "Internal Server Error" }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Implemented" + }, "503": { "content": { "application/problem+json": { @@ -133280,7 +133466,7 @@ "bearerAuth": [] } ], - "summary": "Create an account-wide collection group. Not idempotent: a retry after a lost response creates a second group.", + "summary": "Not supported: personal collection groups were removed, and this operation answers capability_unsupported.", "tags": [ "collections" ], @@ -133416,6 +133602,16 @@ }, "description": "Internal Server Error" }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Implemented" + }, "503": { "content": { "application/problem+json": { @@ -133432,7 +133628,7 @@ "bearerAuth": [] } ], - "summary": "Read the canonical collection editor state and its strong validator.", + "summary": "Not supported: personal collection groups were removed, and this operation answers capability_unsupported.", "tags": [ "collections" ], @@ -133650,6 +133846,16 @@ }, "description": "Internal Server Error" }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Implemented" + }, "503": { "content": { "application/problem+json": { @@ -133666,7 +133872,7 @@ "bearerAuth": [] } ], - "summary": "Replace the order of the account's collection groups. Retries are not safe after an intervening mutation.", + "summary": "Not supported: personal collection groups were removed, and this operation answers capability_unsupported.", "tags": [ "collections" ], @@ -133848,6 +134054,16 @@ }, "description": "Internal Server Error" }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Implemented" + }, "503": { "content": { "application/problem+json": { @@ -133864,7 +134080,7 @@ "bearerAuth": [] } ], - "summary": "Delete a collection group; its collections become ungrouped.", + "summary": "Not supported: personal collection groups were removed, and this operation answers capability_unsupported.", "tags": [ "collections" ], @@ -134027,6 +134243,16 @@ }, "description": "Internal Server Error" }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Implemented" + }, "503": { "content": { "application/problem+json": { @@ -134043,7 +134269,7 @@ "bearerAuth": [] } ], - "summary": "Read the canonical collection editor state and its strong validator.", + "summary": "Not supported: personal collection groups were removed, and this operation answers capability_unsupported.", "tags": [ "collections" ], @@ -134275,6 +134501,16 @@ }, "description": "Internal Server Error" }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Implemented" + }, "503": { "content": { "application/problem+json": { @@ -134291,7 +134527,7 @@ "bearerAuth": [] } ], - "summary": "Update a collection group; omitted members are unchanged. Retries are not safe after an intervening mutation.", + "summary": "Not supported: personal collection groups were removed, and this operation answers capability_unsupported.", "tags": [ "collections" ], @@ -135403,10 +135639,12 @@ } }, { + "description": "Omit: personal collection groups are no longer supported, and any value is a validation failure", "explode": false, "in": "query", "name": "group_id", "schema": { + "description": "Omit: personal collection groups are no longer supported, and any value is a validation failure", "type": "string" } } @@ -135759,7 +135997,7 @@ "bearerAuth": [] } ], - "summary": "Replace the order of the collections in one group (or the ungrouped section). Retries are not safe after an intervening mutation.", + "summary": "Replace the order of the acting profile's own collections. Retries are not safe after an intervening mutation.", "tags": [ "collections" ], @@ -137189,6 +137427,16 @@ }, "description": "Internal Server Error" }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Implemented" + }, "503": { "content": { "application/problem+json": { @@ -137205,7 +137453,7 @@ "bearerAuth": [] } ], - "summary": "Update the creator's collection; omitted fields are unchanged.", + "summary": "Update the creator's collection; omitted fields are unchanged. Another profile's shared collection answers permission_denied.", "tags": [ "collections" ], @@ -173436,8 +173684,7 @@ "tags": [ "profile_sections" ], - "x-silo-class": "profile_scoped", - "x-silo-service-backed": true + "x-silo-class": "profile_scoped" } }, "/api/v2/profile/sections/settings": { diff --git a/contracts/api/v2/scenarios/api/api-v1-profile-sections.json b/contracts/api/v2/scenarios/api/api-v1-profile-sections.json index fa7b108816..a3c4c96d06 100644 --- a/contracts/api/v2/scenarios/api/api-v1-profile-sections.json +++ b/contracts/api/v2/scenarios/api/api-v1-profile-sections.json @@ -1086,7 +1086,7 @@ { "id": "overrides_put.user_added_gate", "category": "authorization", - "description": "A non-admin adding a custom (admin-only recipe) section while the server setting is off is 403 custom_disabled.", + "description": "A non-admin adding a new Editor's picks (admin-only recipe) section is 403 custom_disabled.", "principal": { "class": "profile" }, @@ -1344,7 +1344,7 @@ { "id": "overrides_put.user_added_allowed", "category": "authorization", - "description": "With sections.allow_profile_custom_sections on, the same request is accepted.", + "description": "A non-admin may always add a rule row (custom_filter); a stored false sections.allow_profile_custom_sections is ignored.", "principal": { "class": "profile" }, @@ -1354,10 +1354,20 @@ "overrides": [ { "is_user_added": true, - "user_section_type": "admin_curated_list", + "user_section_type": "custom_filter", "user_config": { - "item_ids": [ - "x" + "match": "all", + "groups": [ + { + "match": "all", + "rules": [ + { + "field": "genre", + "op": "contains", + "value": "Drama" + } + ] + } ] } } @@ -1369,7 +1379,7 @@ "body_kind": "empty" }, "settings": { - "sections.allow_profile_custom_sections": "true" + "sections.allow_profile_custom_sections": "false" }, "fresh_state": true, "v2_expectation": { @@ -1384,10 +1394,20 @@ "overrides": [ { "is_user_added": true, - "user_section_type": "admin_curated_list", + "user_section_type": "custom_filter", "user_config": { - "item_ids": [ - "x" + "match": "all", + "groups": [ + { + "match": "all", + "rules": [ + { + "field": "genre", + "op": "contains", + "value": "Drama" + } + ] + } ] } } @@ -1450,14 +1470,24 @@ { "pointer": "/items/0/user_section_type", "op": "equals", - "value": "admin_curated_list" + "value": "custom_filter" }, { "pointer": "/items/0/user_config", "op": "equals", "value": { - "item_ids": [ - "x" + "match": "all", + "groups": [ + { + "match": "all", + "rules": [ + { + "field": "genre", + "op": "contains", + "value": "Drama" + } + ] + } ] } } @@ -1568,7 +1598,7 @@ { "id": "overrides_put.admin_bypass", "category": "authorization", - "description": "An admin bypasses the custom-section gate.", + "description": "An admin may add a new Editor's picks (admin-only recipe) section.", "principal": { "class": "acting_admin" }, @@ -6347,7 +6377,7 @@ { "id": "section_flags.ok", "category": "status_headers", - "description": "Exposes the custom-sections server flag to any profile: 200 JSON.", + "description": "Exposes the rule-row flag, always true, to any profile: 200 JSON.", "principal": { "class": "profile" }, @@ -6375,7 +6405,7 @@ }, "v2_expectation": { "kind": "same", - "summary": "The fixture server flag retains its boolean meaning and exact response fields.", + "summary": "The flag is always true; v2 keeps the field, deprecated, with the same response fields.", "recorded_in": "internal/apiv2/profile_sections.go", "operation_id": "getProfileSectionFlags", "method": "GET", @@ -6402,7 +6432,7 @@ { "pointer": "/allow_profile_custom_sections", "op": "equals", - "value": false + "value": true } ] } @@ -6411,7 +6441,7 @@ { "id": "section_flags.off", "category": "data_meaning", - "description": "Unset means false.", + "description": "Profiles may always add rule rows: with nothing stored the flag is true.", "principal": { "class": "profile" }, @@ -6424,13 +6454,13 @@ { "pointer": "/allow_profile_custom_sections", "op": "equals", - "value": false + "value": true } ] }, "v2_expectation": { "kind": "same", - "summary": "The fixture server flag retains its boolean meaning and exact response fields.", + "summary": "The flag is always true; v2 keeps the field, deprecated, with the same response fields.", "recorded_in": "internal/apiv2/profile_sections.go", "operation_id": "getProfileSectionFlags", "method": "GET", @@ -6457,7 +6487,7 @@ { "pointer": "/allow_profile_custom_sections", "op": "equals", - "value": false + "value": true } ] } @@ -6466,7 +6496,7 @@ { "id": "section_flags.on", "category": "data_meaning", - "description": "The string true turns it on.", + "description": "A stored false is ignored: the flag stays true.", "principal": { "class": "profile" }, @@ -6484,11 +6514,11 @@ ] }, "settings": { - "sections.allow_profile_custom_sections": "true" + "sections.allow_profile_custom_sections": "false" }, "v2_expectation": { "kind": "same", - "summary": "The fixture server flag retains its boolean meaning and exact response fields.", + "summary": "The flag is always true; v2 keeps the field, deprecated, with the same response fields.", "recorded_in": "internal/apiv2/profile_sections.go", "operation_id": "getProfileSectionFlags", "method": "GET", @@ -6543,7 +6573,7 @@ }, "v2_expectation": { "kind": "same", - "summary": "The fixture server flag retains its boolean meaning and exact response fields.", + "summary": "The flag is always true; v2 keeps the field, deprecated, with the same response fields.", "recorded_in": "internal/apiv2/profile_sections.go", "operation_id": "getProfileSectionFlags", "method": "GET", @@ -6570,7 +6600,7 @@ { "pointer": "/allow_profile_custom_sections", "op": "equals", - "value": false + "value": true } ] } diff --git a/docs/admin-api.md b/docs/admin-api.md index b68f1214d3..8f67d33f84 100644 --- a/docs/admin-api.md +++ b/docs/admin-api.md @@ -1745,12 +1745,10 @@ sensitive and machine-managed keys with 404. Missing and empty values also remai 404. The setup wizard uses this route for its Redis configuration read and treats only 404 as absence, rejecting stale responses after an authority change. -`GET /api/v2/admin/settings/sections` reuses the existing profile-section flag -reader: read failures preserve the disabled default. It requires acting-admin -authority. `GET /api/v2/admin/playback-routing/capabilities` exposes the shared -routing vocabulary under the same authority; these are configuration choices, -not evidence of available worker capacity. No recorded first-party consumer uses -these two discovery reads. +`GET /api/v2/admin/playback-routing/capabilities` exposes the shared routing +vocabulary under acting-admin authority; these are configuration choices, not +evidence of available worker capacity. No recorded first-party consumer uses +this read. ### Jellyfin compatibility status @@ -1891,16 +1889,12 @@ web query and manual-refresh cache writer share the captured authority key and reject results decoded after an authority switch. These are aggregate observations, not an atomic cluster snapshot. No native or Jellyfin caller uses this admin read. -`PUT /api/v2/admin/settings/sections` replaces `allow_profile_custom_sections`. -The administrator GET on the same path now returns an actor/profile-bound ETag -and supports conditional reads. PUT requires `If-Match`, accepts `If-None-Match` as an additional exclusion, and -evaluates both against current canonical state inside the existing settings -transaction; stale state returns 412, and unchanged state performs no write. -The required boolean rejects omitted and null values. The response contains the -canonical flag and its ETag. The profile-facing flag reader keeps its existing -disabled default on read failure; the write fails closed without an atomic store. -No first-party or internal writer is recorded, so no new UI or native flow is added. -The bridge writer and profile section enforcement remain unchanged. +There is no setting for whether profiles may add rule rows: they always may. +`GET`/`PUT /api/v2/admin/settings/sections` (`getAdminSectionSettings`, +`updateAdminSectionSettings`) were removed before the v2 lock. The frozen v1 +`GET /api/v1/admin/settings/sections` answers `allow_profile_custom_sections: +true`, and v1 `PUT` checks that the body is JSON, changes nothing, and answers +the same. A stored `sections.allow_profile_custom_sections` row is ignored. ### Offline-download preparation diff --git a/docs/architecture/api-contract.md b/docs/architecture/api-contract.md index 19bd5a4779..7b9d3e47b9 100644 --- a/docs/architecture/api-contract.md +++ b/docs/architecture/api-contract.md @@ -1404,7 +1404,18 @@ types or an undecodable image is `422` at `body.avatar`, an oversized avatar is server without an upload store answers `503`; section overrides drop the `/reset` suffix (`DELETE` on the same resource), take `scope` and `library_id` as query parameters on every method, and read back in `snake_case` like the write (the Phase 1 catalogs flagged v1's GET/PUT -casing mismatch). Every profile mutation in the section is demo-restricted on v2 (v1's demo guard lists none of them), and `createProfile`'s `Location` names the `PATCH`/`DELETE` resource; the created profile is read back through `listProfiles`. +casing mismatch). `getProfileSectionSettings` also returns `default_title`, the administrator's +own title for each admin row next to the profile's effective `title`, so a client can show that a +profile renamed a row and offer the original name back (saving an empty `title` override restores +it); it is empty for a profile-built row, and v1 does not return it. `getProfileSectionFlags` +keeps `allow_profile_custom_sections` for clients that read it, but it is deprecated and always +`true`: profiles may always add rule rows (`custom_filter`), with no server setting. Whether a +profile that is not an admin may add a new row of a recipe is that recipe's `admin_only` in +`listSectionRecipes`; clients follow it instead of the flag. On both versions, +`replaceProfileSectionOverrides` refuses only a profile that is not an admin adding a new row of +an `admin_only` recipe (Editor's picks, `admin_curated_list`); a row of that kind already saved on +the page is kept and may change. v1 answers `403 custom_disabled` with its frozen message; v2 +answers `403 permission_denied` saying only an admin can add an Editor's picks row. Every profile mutation in the section is demo-restricted on v2 (v1's demo guard lists none of them), and `createProfile`'s `Location` names the `PATCH`/`DELETE` resource; the created profile is read back through `listProfiles`. **Settings section (Phase 4).** Operations: `getSettingsContract` (serves both v1 `/settings/contract` and `/settings/manifest`), `getSettingsContractCapabilities` (also v1 @@ -2110,9 +2121,45 @@ multipart create/update operations. Creation and provider imports mint server ID non-retryable. Sync is synchronous and non-retryable: the existing scheduler guard is local to one process and does not provide cluster-wide coalescing or a durable request identity. -Collection and group edits, deletes, and ordering require `If-Match`. Clients first load the -canonical representation: `GET /collections/{id}`, `GET /collections/groups/{id}`, -`GET /collections/order?group_id=...`, `GET /collections/groups/order`, or +Ownership and visibility (#1615) are one rule, applied on every read and write path and in both +user stores. A native personal collection has exactly one owner, `creator_profile_id`. Profile +`P` on login `U` sees a collection when it belongs to `U` and either `P` created it or `is_shared` +is true; the decision is made at read time (`userstore.Collection.VisibleTo`), so profiles created +later see shared collections and no per-profile rows exist. A collection the profile cannot see +answers `404 not_found` on every operation; a visible collection the profile does not own answers +`403 permission_denied` on every mutation, including sync. Members and `item_count` are limited to +what the owner can access and then to what the viewer can access; personalized smart rules and +display filters use the viewer's state. Audiobookshelf (beta) collections and playlists share the +PostgreSQL table; only rows with `native = TRUE` are personal collections, and the column defaults +to false so another writer can never leak into native listings. See +[the personal collections API](../collections-api.md). + +Migration `20261003235347_personal_collection_login_sharing` converts existing rows. A shared +collection stays shared only when its allow list covered every profile on its login; any other +shared collection becomes private. Release notes for a build containing this migration must say +so and must carry a maintenance requirement: stop every API replica before a new replica runs +migrations, and do not restart an old replica against the migrated database. An old replica +creates collections without `native`, so they never appear in native listings, and it can still +store an `is_shared` meant for a subset allow list, which the new rule reads as shared with the +whole login. The later migration that drops `user_personal_collection_profiles` first repeats the +`native` backfill and the sharing rule, so rows an old replica wrote are recovered without +widening access. + +`listCollections` returns the profile's own collections in its order, then other profiles' shared +collections grouped by owner, each in its owner's order. Each profile has one flat order of its own +collections (`sort_order` numbered per creator). `reorderCollections` accepts only a permutation of +the acting profile's own collections; any other ID is a `422 validation_failed` at +`body.ordered_ids`. The order validator remains account-wide, so another profile's edit can make a +profile's order tag stale; clients recover from the 412 as for any stale tag. + +Personal collection groups are removed in two phases. Today `groups` is always `[]`, `group_id` +always null, the `groups` capability false, and the six group operations and a `group_id` update +answer `501 capability_unsupported`. The members and operations leave `/api/v2` once shipped Apple +and Android builds tolerate their absence, before the lock. `CollectionCapabilities.login_sharing` +tells clients the server implements this model. + +Collection edits, deletes, and ordering require `If-Match`. Clients first load the +canonical representation: `GET /collections/{id}`, `GET /collections/order`, or `GET /collections/{id}/items/order`. Paths in this section have the `/api/v2` prefix. Each response supplies a strong ETag bound to the representation, account, profile, and access scope. Canonical collection editors omit the volatile presigned poster URL; display listings @@ -2120,7 +2167,7 @@ continue to provide artwork. Personal collection detail responses include the vi `item_count`, so their ETag also binds that count. A catalog or watch-state change that changes the count invalidates an earlier tag at precondition evaluation, even without a collection edit. The stored collection revision continues to guard concurrent definition edits in the write -transaction. Ordering writes use PUT, group and collection partial edits use +transaction. Ordering writes use PUT, collection partial edits use PATCH, and a successful delete returns 204 without an ETag. Storage compares the version and advances it in the transaction that applies the write. Missing preconditions return 428; stale preconditions return 412 with the current authorized validator. Clients must not automatically retry or implicitly replace the observed validator with a wildcard. Web editors retain the observed validator and preserve drafts @@ -2141,7 +2188,7 @@ Artwork changes use `PUT /collections/{id}/poster` with either a bounded multipa then changes the poster; artwork failure leaves the saved collection intact and is reported separately. Membership and artwork operations check creator ownership, and item additions also require catalog visibility. Adding an existing native member preserves its position; order changes -use the explicit ordering operation. Shared viewers can read permitted collections but cannot mutate them. +use the explicit ordering operation. Shared viewers can read shared collections but cannot mutate them. Native membership operations preserve audiobook chapter entries in the same storage table. Collection capabilities describe the acting account's selected user store: @@ -2151,12 +2198,13 @@ Collection capabilities describe the acting account's selected user store: | Existing manual create/read/update/delete and membership | Supported | Supported | | Stable bounded manual continuation | Supported | Supported | | Guarded definition update/delete | Supported | Supported | -| Groups and collection/item ordering | Supported | Pre-existing unsupported behavior | +| Collection/item ordering | Supported | Pre-existing unsupported behavior | +| Groups | Removed (#1615) | Removed | | Imported collections and sync | Supported | Pre-existing unsupported behavior | | Collection artwork | Supported | Pre-existing unsupported behavior | -The `groups`, `imports`, `artwork`, and `item_reorder` flags let the bundled web hide unsupported -actions. Unsupported store features answer the structured 501 `capability_unsupported` problem; +The `imports`, `artwork`, and `item_reorder` flags let the bundled web hide unsupported +actions; `groups` is always false. Unsupported store features answer the structured 501 `capability_unsupported` problem; they are not silently accepted. This migration does not add those feature families to SQLite. Apple and Android still need to adopt these v2 collection operation mappings, ID/envelope and @@ -2315,6 +2363,11 @@ alone would allow that snapshot to miss a newly committed reference. Missing out can still be removed or replaced. Section-managed cleanup also checks management mode under the parent lock and reports whether it actually deleted the collection. +`listAdminCollectionSections` and the `home_row_count` and `row_count` members of +`listAdminCollections` items read those JSON references from the administrator page layouts +without locks; the counts come from one grouped query per list request. Profile-added rows live +in profile overrides and are not counted. The counts stay off the canonical collection read. + Section storage writers use serializable transactions with sorted collection parents, section targets, and scope counters. Durable section and scope revisions cover definition, enabled, featured, membership, and order changes, including generated sections and default replacement. @@ -2368,7 +2421,13 @@ defaults and requires the captured scope ETag. Its response is the refreshed can with its ETag; clients refetch definitions after replacement. `reset_profiles` selects the separate all-profile reset capability described above; an unsupported reset fails before definition writes. Creation and bulk creation use POST, while the retained preview POST samples recipe results using the -requesting profile's access filter without saving a definition. Capabilities report whether the +requesting profile's access filter without saving a definition. Preview items carry the same +presigned, short-lived `poster_url` a saved row serves at its default size, and omit it when the +item has no poster; storage keys are never serialized. Creation, bulk creation, preview, +and a PATCH that changes `section_type` or `config` run the recipe's own config check and answer +`validation_failed` when it fails, for example an Editor's Picks list with no items; a PATCH that +leaves both unchanged, including one that echoes their stored values, does not re-check the stored +config. The frozen `/api/v1` single create and update routes do not run this check. Capabilities report whether the service, preview, and atomic profile reset are available. Clients do not automatically replay administrator section operations after a conflict or a partial multi-request flow. diff --git a/docs/architecture/collection-editor.md b/docs/architecture/collection-editor.md new file mode 100644 index 0000000000..a3c9fe20a8 --- /dev/null +++ b/docs/architecture/collection-editor.md @@ -0,0 +1,291 @@ +# Collection editor + +The web edits every collection on one page, for server (library) and personal collections alike: +`web/src/pages/CollectionEditorPage.tsx`. This page covers how that page saves, because the +collection's titles and its other fields reach the server by different routes with different +tokens. Every type uses the page: Manual, Smart and Synced list, to create and to edit. + +## Routes + +`/admin/collections/new`, `/admin/collections/:id/edit`, `/collections/new` and +`/collections/:id/edit` each sit under one pathless layout route per family, so the page stays +mounted from `/new` to `/:id/edit`. After Create the page replaces the URL with the new +collection's edit URL and keeps the same draft, with focus in the title search. The page reports +itself clean until the URL has moved, so the unsaved-changes guard never asks during that step. + +Creating starts from one **New collection** button on each collections list, which opens the type +picker (`web/src/components/collections/NewCollectionPicker.tsx`) over the list as +`?dialog=new`. Its Manual, Smart and Synced list cards are links to `/new?type=…`, carrying the +list's selected library on the server list. There they also hand the editor the list's URL, +without the open dialog, so its Back lands on the same view. The Synced list card waits for the scope's +capabilities and is disabled when `import_sources` is empty. A `/new` URL with no `type` redirects +to the list with the picker open, so older links keep working. + +A viewer who can't change the collection never sees a form: the page sends them to the +collection's own page with `?notice=read-only`, which shows who can change it. Personal ownership +fails closed, so the page waits for the acting profile before it decides. + +## What saves when + +- **Titles save as you change them.** Adding, removing and reordering a Manual collection's titles + call the item routes at once (`PUT …/items/{item_id}`, `DELETE …/items/{item_id}`, + `PUT …/items/order`). A removal can be undone for six seconds; Undo puts the title back at its + old position. +- **Everything else waits for Save.** Name, description, libraries, a Smart collection's rules and + Order, artwork and the switches in Where it shows are a draft (`CollectionDraft`, `web/src/lib/collections/scope.ts`). The save bar + names the fields that are pending and says titles are already saved. Discard never touches + titles. +- **Before the collection exists**, picked titles are staged in the draft. Create sends the POST, + then one `PUT` per staged title in order. Titles that fail stay listed and marked, with Try + again. +- **Artwork** saves after the collection: a file or link uploads, a staged removal sends `DELETE + …/image`, and a new file in the same slot replaces the image without a DELETE. A slot whose + upload fails after the collection saved stays staged and offers Retry. +- **Pin** (`featured`, "Pin to the start of its shelf") is not part of the draft. It is set in + Arrange. A collection created in the editor is created unpinned, and an editor PATCH leaves + `featured` out, so a stale editor can't undo a Pin set elsewhere. + +## What Pin does + +The catalog lists a library's collections pinned first (`ListByLibrary` orders by `featured`, +then position). That order reaches viewers in two places: + +- **A shelf set to Your order**, and No heading, shows its pinned collections first, then the rest + in their stored order. Arrange draws them in a band at the start of the shelf. A move never places + a card above the band, and the order a move saves is the order Arrange shows. A shelf that sorts + itself (by name, recently updated or most titles) ignores `featured` (`applyCollectionSort`). +- **The Server collections list** on every profile's Collections page is capped per library + (`ListServerCollections`, `capServerCollections`), so pinned collections are the ones that lead + it, whatever their shelf's order. This is why Pin stays available on a shelf that sorts itself, + and why its help line names that list. + +`featured` is a column on the collection, so a Pin set from one library's Arrange applies in every +library the collection is in, while its shelf and position stay per library. Pin's help line says so +when the collection is in more than one library. + +Arrange sends a Pin as a PATCH of only `collection_type` and `featured`, with `If-Match` from a fresh +read of the collection. + +## Tokens and merging + +Guarded writes carry `If-Match`. Every title write changes the collection's revision, and so its +ETag, without the editor's draft changing. To keep Save working: + +1. After each title write the editor reads the collection again (`syncWithServer` in + `useCollectionDraft`, `web/src/hooks/queries/collectionScope.ts`) and keeps the fresh ETag. +2. It merges the fresh copy into the draft with a three-way merge against the copy the draft + started from (`mergeDraft`, `web/src/lib/collections/draft.ts`). Per field: changed only on the + server, the draft takes the server's value; changed only in the draft, the draft keeps it; + changed to the same value, no conflict; changed differently, the draft keeps its value and the + field is a conflict. The fresh copy becomes the new base. A conflict waiting for Keep mine or + Use theirs stays a conflict on later reads for as long as the draft and the server still + differ on that field. +3. A Save that still answers `412` reads and merges the same way. With no conflicting field it + retries once with the new ETag and no prompt. With a conflict it stops and shows the banner + "This collection changed since you opened it." with Keep mine and Use theirs. + +A reorder uses the item-order ETag from `GET …/items/order`, read again after every title write, +not the collection's ETag. + +Because each save merges against a fresh read, two editors on different nodes, or an editor and a +sync, never overwrite each other's fields silently: the later save either carries the other +change forward or stops at the conflict. + +## Smart rules and the live preview + +A Smart collection's Contents is the Home rows rule sentence (`RuleBuilder`), with the libraries +inside it: a server collection needs at least one, and a personal one with none matches every +library the profile can see. Rules about the viewer (Watched and the like) are offered only on a +personal collection. A rule the builder can't show stays as a locked line and is saved unchanged +until someone removes it. The draft's libraries are the rules' `library_ids`; a library change +counts once, as Libraries. + +The preview sends the whole draft `query_definition`, across every chosen library, to the scope's +preview route (`POST …/collections/preview`, 24 titles), 300 ms after the last edit. It shows the +unsaved rules, so the save bar says the preview already shows them. A rule set that matches nothing +still saves. + +A Smart collection may carry a stored default sort in `sort_config` (`field` and `order`), which +wins over the rules' sort when viewers open it. The Order block names it with Clear. Changing the +sort or direction clears it too, so the new Order takes effect; clearing drops only `field` and +`order` and keeps any other `sort_config` setting. An untouched stored sort is sent back as it was. + +## Creating a Synced list + +`/new?type=synced` (with `&source=mdblist`, `tmdb_chart` or `tmdb_list` to pick the opening tab) +shows one source panel. Its tabs follow the scope's `import_sources`. The panel's state is the +draft's `synced` member (`web/src/lib/collections/synced.ts`): the list it will follow, the typed +links, max titles and the schedule. + +- **Picks fill only untouched fields.** A ready-made pick (a template) or an MDBList search result + fills Name, Description, max titles, schedule and the poster. The draft remembers what the last + pick filled; a field that still holds that value (or is blank) takes the next pick's value, and + a field changed by hand stays and is named under Name ("Kept your name"). A search result brings + only its own name and description, never a template's. +- **Only creatable templates show.** A template appears when its source is one of the scope's + `import_sources` and it needs no profile; Discover and Franchise templates come only with Starter + packs. Templates that ask for a link are left out, because the panel has its own link fields. +- **Links.** A pasted MDBList link loses its `?query`, `#fragment` and trailing slash before it is + sent; a `/json` ending is kept. The server normalizes and allowlists it again. A TMDB list link + is sent as typed. +- **TMDB charts** offer only the choices `validateTMDB` accepts (`web/src/lib/collections/tmdbSources.ts`). +- **Libraries.** A list of only movies or only shows leaves out libraries of the other kind and + unticks them. +- **Create** posts the scope's import route (`POST …/collections/import/{mdblist,tmdb,tmdb-list}`), + which runs the first sync, then saves artwork as for other collections. A server list is + imported with `featured: false`. A pick's poster is sent as `poster_url` unless the poster slot + replaces or removes it. The personal import takes no Collections tab choice, so with the switch + on the web reads the new list and sends a guarded `PATCH` with `include_in_server_collections`. + The page then moves to the new list's edit URL. +- **Schedules.** A server list takes a cron schedule, labelled with the answering node's offset + from `schedule_time_zone`, because cron runs in the node's local zone. A personal list takes a + named schedule (Manual only, Daily, Weekly, Monthly); a template's cron maps to the nearest one, + never more often than daily. + +## Editing a Synced list + +A saved Synced list keeps what it follows in the draft's `list` member (`ListDraft`, +`web/src/lib/collections/scope.ts`): the link, chart or TMDB collection ID, max titles and the +schedule. They save with the rest of the draft and merge three-way like other fields; what the +list follows counts once, as the list. + +- **What can change depends on the source.** A server MDBList or TMDB list changes its link; a + server TMDB chart changes its chart or moves to a TMDB list; a franchise list changes its TMDB + collection ID. A profile changes its MDBList or TMDB list link; a chart it follows stays as it was + made. Discover lists (from Starter packs) and legacy Trakt lists show their source read-only. +- **What a server save sends.** The PATCH carries every field and rebuilds the source whole + (`source_url` and `source_config`), as the server stores it: a changed MDBList link loses its + `?query`, `#fragment` and trailing slash, and a TMDB list link becomes its canonical + `https://www.themoviedb.org/list/{id}`. A Discover list and a franchise list with no ID yet send + no source unless max titles changed, and then send their stored config with the new limit; every + Trakt list sends no source, so the server keeps the stored one. A new chart unticks libraries that + can't hold its titles, as on create, because the server doesn't check library kinds on update. A Trakt list's + libraries and max titles are locked, and a stopped Trakt schedule can't be turned on, because + the server refuses those changes. A save never sends `featured`. +- **What a personal save sends.** Name, sharing, libraries, the Collections tab switch and Show + only always go; description, order, the link (`source_url`), max titles (`max_items`, `0` for the + whole list) and the schedule go only when they changed. +- **Schedules.** A personal list's schedule is shown by its cadence name (`sync_cadence`), never as + cron. A cadence the names can't express reads "Custom schedule (current)" and is never sent back; + picking another sends `sync_schedule`. The picker stays locked until the collections + capabilities report `sync_schedule_editable`. A server list's cron is labelled with the answering + node's offset, as when it was created. +- **Sync status.** The panel shows when the list last synced, when it syncs next, and how many of + the list's titles a sync skipped because they are in none of its libraries. That count comes + from a sync run (`items_unmatched`); the collection doesn't store it, so it is known only after + Sync now on this page. A failed last sync shows its reason at the top, with Sync now on server + lists. +- **Sync now** (header ⋯, server lists only) moves the collection's revision. When it finishes, the + editor reads the collection again and merges (`syncWithServer`), so the next Save sends the + current ETag. Personal editors have no Sync now; a profile syncs its lists from their cards on + the Collections page. + +## Title search + +The title search uses the viewer-scoped catalog query (`POST /api/v2/catalog/query`), so it offers +only titles the viewer can see: titles from libraries the profile can't open and titles above its +rating ceiling are not returned. A server collection with one library searches that library; with +several, the web searches each (at most four requests at once) and merges the results by rank. A +personal collection searches every library the profile can see. The search is a convenience, +not the access check: the personal item route refuses a title the acting profile can't see, and a +server collection's members are filtered per viewer when they are read. + +## Links into Home rows + +Both Home rows pages (`/admin/home-rows` and `/settings/home-screen`) take link parameters next to +`?page=`, read by `web/src/lib/homeRows/rowLinks.ts` and followed by +`web/src/components/homeRows/useRowLinks.ts`. Each page reads them once and drops them from the +address, so a reload doesn't repeat them. + +- `?add=collection:library:` (both pages) or `collection:user:` (Settings > Home Screen + only) opens Add row at step 2 on that collection. It waits until the page's rows are loaded and + it is known whether the page can change, then for the page's collection options, and opens only + when the id is among them. A refresh that fails gets the options-failed toast even when the old + options hold the id, since they may hold a collection hidden or deleted since. A hidden, deleted, unknown or unshared collection, or a page that + can't change, gets a toast and nothing opens; options that fail to load get their own toast. + Options read before a collection was made or deleted are + refreshed first, and a match or a miss waits for that refresh: the collection scopes' + `invalidate` marks the admin collection list, the personal list and the library tabs stale, and + those are what the options read. +- `?edit=` opens that row in Edit row; a row that is gone gets a toast. +- `?return=` makes the dialog's back link "Back to *collection*" and, after Add row, goes to + that path with a toast that offers to move the new row (`?edit=` on the same page). Both replace + the Home rows history entry. Settings > Home Screen queues its saves, so there it goes back only + once the save lands and the row is on the page, even when that takes a later read because the + one after the save failed; a failed save keeps the viewer on Home rows with the save's error + toast. The toast's position counts the page's rows when the add lands, not when the dialog + opened. A link that arrives while the dialog is open starts a fresh dialog on its target. After Add row the navigation state carries `addedRow` (the + `AddedRowState` type: new row id, surface, page and position) so + the collection's page can show which row is new. The path is honoured only when it starts with + `/admin/collections/` or `/collections/` and neither it nor its decoded form holds a backslash, + `//`, a control character or a `.`/`..` segment; anything else is ignored, so the parameter + can't send a viewer off the site. + +## Where it shows: rows + +A server collection's Where it shows panel lists the administrator Home and library page rows that +show it, from `listAdminCollectionSections`, and offers **Add as a row** (Home, then the library +pages of the collection's own libraries, then the other libraries). The same rows give the header +its "On Home and the _Kids_ page" (turned-off rows left out). Rows profiles add to their own Home +are not listed, because the route reads the administrator page layouts only. When the collections +capabilities don't report `section_references`, the list is left out and Add as a row stays. + +- **Add as a row** goes to admin Home rows with `?page=…&add=collection:library:` and, from the + editor, `?return=` set to the editor's own URL (see Links into Home rows). The List's Add to Home + and Add to a library page send no `return`, because the List's address is not a collection page + the allowlist accepts; Home rows keeps its own after-add step. With unsaved changes the editor + asks first: Save and continue saves and goes only once nothing is left unsaved (artwork that + failed to upload stays in the editor with its error), Discard changes drops the draft (titles + stay), Cancel stays. Discard changes is off when the collection is saved as hidden, since only + saving would show it. Save and continue is off whenever Save is, including while a field + changed in both places waits for Keep mine or Use theirs. A collection not created yet, or one hidden from its Collections tab, + can't be added, because a row couldn't open its See all; the button says why. +- **Back from Home rows**, the `addedRow` state names the new row, which is highlighted once the + rows list includes it. The editor sends Home rows the List view it was opened from as history + state, and Home rows hands that state back with `addedRow`, so the editor's Back still reaches + that view. +- **Hiding** a collection rows show asks first, naming the pages the rows are on, as the List does + with its `row_count`. While the rows list hasn't loaded (or failed), the List's `row_count` for + the collection still triggers the question, without naming pages. The switch stays part of the + draft until Save. +- **Deleting** a collection rows show lists them, each with Open row, and offers to delete the + rows and then the collection. Before any row goes, the editor (and the List) reads the collection + again: when its ETag no longer matches the one the delete would send, nothing is deleted (the + collection's `DELETE` would answer `412` after its rows were gone) and the new copy is taken, + so the next Delete sends the current token. Each row is read again for its ETag right before its `DELETE`; a + row that is already gone, or that now shows another collection, is left alone (the list of rows + can be stale, and the collection's own `DELETE` still refuses while any row uses it). The first + row that fails stops the run before the collection is touched, and the dialog names the rows + that still show it. When the rows list fails to load, the delete waits for Retry only if the + List's `row_count` says rows show it; otherwise the plain confirm deletes and the server's + `409` (problem type `conflict`) guards a row added since. On the List, that `409` is shown as "Rows + still use it. Remove them first." without `section_references`, and with it the List falls back + to the rows list for a collection its counts showed as unused. The editor shows the server's + message and reads the rows again (only when the server reports rows). +- **Row places** ("Home · row 6 of 9") are each row's rank in its page's order + (`getAdminSectionOrder`, read once per page alongside the rows), because stored positions skip + numbers after a row is deleted. + +## Where it shows: personal rows + +A personal collection's Where it shows panel lists the rows on the viewer's own Home and on the +library page of every library the profile sees that show it, read from +`GET /api/v2/profile/sections/settings`, one read per page. Every page is read, not only those of +the libraries the collection matches, because Home Screen offers a personal collection on any page +and its libraries can change after a row is added. That is the read Settings > Home Screen makes, so +both share one cache and a row added there is listed on the way back. Pages are read only once the +panel comes near the screen, and at most four at once (the poster peeks' limit), and not before the +profile's hidden libraries are known. The header's "On my Home…" line comes from the same read, so +it appears only after the panel has been near the screen. A row's place ("My Home · row 4 of 8") is +its rank on its page, hidden rows counted; a row the profile hid reads "Hidden" and is left out of +the header's "On my Home". Rows other profiles make from a shared collection are not listed. + +- **Add as a row** offers My Home and the matched library pages, and goes to Settings > Home Screen + with `?page=…&add=collection:user:&return=`, asking first about unsaved changes + as the server editor does. It is off until the collection is created. The editor never writes a + profile's rows itself; only Home Screen's Add row does. +- **Elsewhere:** an own card's ⋯ on the Collections page has Add to my Home…. A collection page the + viewer can't change (shared with them, or a server collection for a viewer who isn't acting as + admin) shows Add to my Home, with the viewer's library pages in its ⋯. These links carry no + `return`, because neither page is a path the allowlist accepts. Shared with me cards keep no + buttons (#195 W7). diff --git a/docs/architecture/collection-posters.md b/docs/architecture/collection-posters.md index f08ca7cc3d..c56381752e 100644 --- a/docs/architecture/collection-posters.md +++ b/docs/architecture/collection-posters.md @@ -1,8 +1,9 @@ # Collection posters -A server collection's poster is either assigned or generated. This page covers the rules for -generated posters, which differ by viewer, and what code that returns a collection poster must -do. +A collection's poster is either assigned or generated. This page covers the rules for generated +posters, which differ by viewer, and what code that returns a collection poster must do. Server +collections and personal collections share the machinery (`internal/catalog/collection_collages.go`); +[Personal collections](#personal-collections) lists where they differ. ## Assigned posters @@ -17,9 +18,10 @@ A collection without an assigned poster shows a collage of its members' posters. Each viewer sees the first four members it can access that have a poster, in collection order. Access uses the same predicates as the collection's member list (`itemAccessConditions`: library allow and deny lists, maturity limits, excluded media types), -so a collage never shows a title the viewer's member list leaves out. A collection the catalog -lists from a query (a smart collection, or any collection with a query definition) has no -collage. +so a collage never shows a title the viewer's member list leaves out. A smart collection lists +its members from its query and has no collage. Only `collection_type` makes a collection live: a +query definition on any other type is ignored, and its members are its stored items +(`catalog.ResolveLibraryCollectionMembership`). Each distinct set of source posters is one stored collage, shared by every viewer that selects the same posters: @@ -58,9 +60,47 @@ does this viewer see". Never build a viewer-facing response from `poster_url` al - The v1 and v2 handlers build responses from `withViewerPosters` copies (`internal/api/handlers/library_collections.go`). That covers the library Collections tab, - server collections, and admin responses. + server collections, and admin responses. `/api/v2` collections and collection cards mark a + collage with `poster_is_collage`, so a client can tell it from an uploaded poster. - Jellyfin BoxSets resolve posters for the session's viewer. A collage's image tag carries its key and a signature (32 hex digits), so the image route can serve the tagged collage to a request without a session. Collage URLs are never seeded into the shared compat image cache. Collection list responses differ by viewer. None of them may be cached across profiles. + +## Personal collections + +A personal collection's assigned poster is the one its creator uploaded, linked or imported, +stored in `user_personal_collections.poster_url`. Without one it shows a collage, by the rules +above with these differences: + +- The viewer's filter is `catalog.PersonalCollectionFilter`: for another profile's shared + collection, the viewer's access intersected with the owner's, resolved on every read. Sources + are the first members with a poster in stored order (`position`, then item ID), with the same + predicates as the collection's item count and pages, manga chapters excluded. A smart + collection's sources are the first matches of its query, read like its item pages, so a smart + personal collection has a collage. The display filter is not applied. +- A list read never runs a smart collection's query. A background refresh reads its matches, + builds their collage, and records it in `user_personal_collection_smart_collages`, keyed by + collection and a hash of the viewer's access filter, with a hash of the query definition it was + made for (no collage when the viewer can see no match with a poster). A read serves that + record with one statement for all of a list's smart collections. It queues a refresh when the + record is missing or names another definition (serving nothing until then), or is older than + `SmartRefreshInterval` (15 minutes; serving the recorded collage meanwhile), so a new match + reaches the collage within that time. A record goes with its collection, and with its collage + when that collage is retired as unused. +- `user_personal_collection_poster_variants` holds the collages, keyed by account, collection + and collage key, and goes with its collection or account (`ON DELETE CASCADE`). Its delete + trigger is the same `queue_deleted_collection_collage`. +- Objects live at `user-collection-images/{collection}/collage/{variant}.{key}.webp`, beside + uploaded posters. +- `catalog.PersonalCollectionCollages.Posters` is the read path. Callers group collections by + owner, resolve each owner once, and pass only collections without an assigned poster + (`ownerScopedCollectionReads` in `internal/api/handlers`). +- A create, edit, membership change, poster removal or sync calls `Refresh`, which waits about + two seconds so a burst of changes to one collection shares one build, then builds the acting + profile's collage (the owner's filter for a scheduled sync) through the build queue. +- Only `/api/v2` reads show collages. The `/api/v1` bridge is frozen and keeps returning + assigned posters only. Accounts on the per-user SQLite store keep their collections outside + Postgres and have no collage. +- Jellyfin clients never see personal collections, so jellycompat has no personal collage path. diff --git a/docs/architecture/collection-templates.md b/docs/architecture/collection-templates.md index 24c79f0eb2..f6b8a352c5 100644 --- a/docs/architecture/collection-templates.md +++ b/docs/architecture/collection-templates.md @@ -1,11 +1,11 @@ # Collection templates Collection templates are curated presets for synced library collections. The server owns the -catalog; the admin and personal template galleries render whatever it returns, and template -bundles apply a group of templates in one pass. This page covers what a contributor needs to add -or change a template: registration, validation, the rules the tests enforce, and the poster -artwork. User-facing behavior is documented in the manual at -https://siloserver.org/docs/manage-collections. +catalog; the web offers its templates as ready-made picks in the collection editor's Synced list +step, and template bundles apply a group of templates in one pass. The admin web shows bundles as +Starter packs. This page covers what a contributor needs to add or change a template: +registration, validation, the rules the tests enforce, and the poster artwork. User-facing +behavior is documented in the manual at https://siloserver.org/docs/manage-collections. ## Catalog and registration @@ -58,11 +58,41 @@ https://siloserver.org/docs/manage-collections. `TestPhase3FranchiseTemplatesUseExpectedBands` hold these rules. The last two also pin the template counts (18 popular genres, 18 top-rated genres, 1 kids, 11 franchises including the placeholder); update the count when you add one. -5. Add a `tmdb_discover` or `tmdb_collection` template to a bundle. The gallery can't create - those two sources directly, and `TestBundleOnlyTemplatesAreReachableFromBundles` fails on one +5. Add a `tmdb_discover` or `tmdb_collection` template to a bundle. No import route creates + those two sources, so the Synced list step can't create them and the v2 admin and personal + template lists leave them out. `TestBundleOnlyTemplatesAreReachableFromBundles` fails on one that no bundle references. 6. Add both poster files (see below). +## Starter packs + +The admin web calls template bundles Starter packs (`web/src/components/collections/StarterPacksDialog.tsx`, +helpers in `web/src/lib/collections/starterPacks.ts`). The Synced list step offers single +templates only, never bundles. The rules the web keeps: + +- **Template summaries only.** The dialog reads `GET /api/v2/admin/collections/template-bundles` + and its `templates` summaries, never the admin template catalog, so Discover and Franchise + templates keep their titles and media kinds even when the catalog leaves them out. +- **One pack at a time.** Each pack is a separate apply with its own dry run and its own result. + The table comes from `POST .../template-bundles/{bundle_id}/apply` with `dry_run: true`; Add + runs the same dry run again, then queues `POST .../apply-job`. While the job runs, the pack, + its libraries and its heroes are locked, so the job matches the table. After the job ends the + dialog stays open, tags the pack Added and checks it again. The Collections page, not the + dialog, refreshes the collections and Home rows the job changed; the dry run's query key sits + outside `["admin", "collections"]` so that refresh doesn't re-run it. +- **No deletes.** The web always sends `delete_existing: false`. The server option stays for + other clients. +- **Heroes are opt-in.** The hero switch is off by default and then the request carries no + `featured` member, so `ClearFeaturedForSurface` never runs and hand-set heroes stay. With the + switch on, the dry run and the job carry the same `featured`; a page set to Keep current is left + out of it. The line for each page names the hero it replaces, read from the admin sections list. +- **Pinned first is the template's.** A pack collection keeps the template's `Featured` flag + (pinned first on its shelf). Collections made in the editor start unpinned. +- **Hidden templates.** A template whose summary has `needs_setup` (the franchise placeholder) is + never shown or counted. The server still creates it when its bundle is applied. +- **Where lists land.** A pack creates no shelves: its collections land in each library's + no-heading group. + ## Source rules `validate` checks each source spec: @@ -82,10 +112,29 @@ https://siloserver.org/docs/manage-collections. `/discover` endpoint unchanged. - `tmdb_collection`: `collection_id >= 0`. Zero is the placeholder for an admin-chosen franchise; `validateTMDBFranchiseConfig` (`internal/catalog/library_collection_service.go`) fails its sync - until a real ID is set. + until a real ID is set. `Template.NeedsSetup` reports this case: bundle apply creates the + collection without a first sync, and the `/api/v2` bundle list marks its summary `needs_setup`. - `trakt`: the validator still accepts it, but the server rejects new Trakt collections with `unsupported_source`. Don't add Trakt templates. +## Templates in the Synced list step + +The web has no template gallery. Templates are suggestions inside the editor's Synced list step +(`web/src/components/collections/editor/SyncedListPanel.tsx`, rules in +`web/src/lib/collections/synced.ts`): + +- **Creatable only.** A template shows when its source is one of the scope's `import_sources` + (`mdblist`, `tmdb` or `tmdb_list`) and it needs no profile. The personal template list is + already filtered that way on the server; the web applies the same filter to both scopes. +- **Named lists only.** An `mdblist` or `tmdb_list` template with an empty `url` is a "bring your + own list" template; the step has its own link fields, so it isn't shown as a pick. +- **A pick is a starting point.** It fills the name, description, poster (`PosterPath`, sent as + `poster_url`), `DefaultLimit` and `DefaultSyncSchedule` only where the person hasn't typed. A + personal list maps the schedule to a named one. A TMDB chart chosen on the chart tab uses the + template with the same preset, media type and window when there is one. +- **Not pinned.** A list made from a pick is created with `featured: false`; the template's + `Featured` applies only through bundles. + ## Poster artwork Every built-in template ships two files named after its ID: @@ -133,8 +182,9 @@ Commands assume the repository root is the cwd. ```sh go test ./internal/collections/templates/... -go test ./internal/api/handlers/ -run 'TestBuiltinTemplateTitleSlugsAreUnique|TestCollectionTemplateHandler|TestLibraryCollectionHandlerListsTemplateBundles' +go test ./internal/api/handlers/ -run 'TestBuiltinTemplateTitleSlugsAreUnique|TestCollectionTemplateHandler|TestLibraryCollectionHandlerListsTemplateBundles|TestV1Template' +go test ./internal/apiv2/ -run 'AdminTemplate|BuiltinBundleOnly|ImportableCollectionTemplates' ``` -When you change the gallery, also run -`pnpm --dir web exec vitest run src/components/CollectionTemplateGallery src/lib/collectionTemplates.test.ts`. +When you change how the web offers templates or Starter packs, also run +`pnpm --dir web exec vitest run src/lib/collections/synced.test.ts src/pages/CollectionEditorPage.synced.test.tsx src/components/collections/StarterPacksDialog.test.tsx src/lib/collections/starterPacks.test.ts src/lib/collectionTemplates.test.ts`. diff --git a/docs/architecture/sqlite-bridge-preflight.md b/docs/architecture/sqlite-bridge-preflight.md index 10bf7bc335..79f7c7af15 100644 --- a/docs/architecture/sqlite-bridge-preflight.md +++ b/docs/architecture/sqlite-bridge-preflight.md @@ -39,8 +39,14 @@ an old source: its missing revision tables and version block import, and inspect does not create the tables or upgrade its version. The inventory does not run the account writer described below. Its manifest records the following mapping requirements: -- Preserve account-scoped profile, collection and history IDs, visibility, - restrictions, timestamps, ordering, history identities and hidden cutoffs. +- Preserve account-scoped profile, collection and history IDs, restrictions, + timestamps, ordering, history identities and hidden cutoffs. +- Apply the personal collection sharing rule instead of copying allow lists. A + shared collection is shown to every profile on the login, so it stays shared + only when its `personal_collection_profiles` rows name every source profile + (its creator implied); otherwise it is imported private. Imported collections + are marked `native`, and no `user_personal_collection_profiles` rows are + written. - Preserve all six canonical settings scopes, null/false/empty distinctions, revisions, mutation replay records and migration rejects. Allocate destination integer surrogate IDs where required, without changing semantic identity. @@ -80,7 +86,8 @@ The writer requires the exact supported version-22 table and column shape, including type, nullability, and primary keys. Unknown or generated columns are rejected. Both legacy session/download tables must be empty. It validates source profile/collection references and central catalog references, refuses existing -mapped target account state, and imports all 27 mapped tables in one transaction. +mapped target account state, and imports all 26 mapped tables in one transaction. +`personal_collection_profiles` is read only to decide each collection's sharing. It does not repair, prune, or partially import unsupported data. Booleans must be 0 or 1. JSON destined for JSONB must be valid and have no duplicate @@ -93,7 +100,8 @@ and value; successful copying does not certify that external encryption keys are available. Canonical settings remain separate from legacy settings. Rows are copied in bounded batches and verified by ordered semantic row counts -and digests against PostgreSQL's target representation. Generated surrogate IDs +and digests against PostgreSQL's target representation. The digest covers +converted values, such as a collection's `is_shared` after the sharing rule. Generated surrogate IDs and deliberately rebased sequence/revision values are excluded from that projection. Collection revision witnesses, including deleted collection tombstones, advance above both source and destination values. Section overrides diff --git a/docs/architecture/v1-scope.md b/docs/architecture/v1-scope.md index 85ee0695c1..b2e4defac5 100644 --- a/docs/architecture/v1-scope.md +++ b/docs/architecture/v1-scope.md @@ -73,6 +73,9 @@ migration ledger. | `POST /api/v1/playback/transcode/start` | Playback protocol v3, [spec](playback-protocol-v3.md) | Client-posted transcode recipes. Superseded by the `quality_change` replan operation: the client sends a label from `playback_plan.available_qualities` and the server picks the encode. Keeping the endpoint keeps a second, unvalidated way to start a transcode that bypasses plan identity entirely. | | The legacy `/api/v1/plex-sync/*` aliases of the webhook-sync surface: `GET /api/v1/plex-sync/connections`, `POST /api/v1/plex-sync/connections`, `DELETE /api/v1/plex-sync/connections/{id}`, `GET /api/v1/plex-sync/connections/{id}/actors`, `PUT /api/v1/plex-sync/connections/{id}/actors`, `POST /api/v1/plex-sync/connections/{id}/webhook/rotate` | API v2 migration, personal-imports section ([ledger](../../contracts/api/v2/migration.json)) | Plex-only projections of the provider-neutral `/api/v1/webhook-sync/*` routes, served by the same handler over the same connection rows, with no first-party consumer. Their successors are the `/api/v2/webhook-sync/*` operations; carrying a second, Plex-shaped spelling of every connection operation into v2 would double the contract for one provider. The public inbound receiver `POST /api/v1/plex-sync/webhooks/{secret}` is not in this entry: external Plex servers were configured with that URL, so it keeps working on v1 (see its ledger row). | | The community theme catalog routes: `GET /api/v1/theme/catalog`, `POST /api/v1/theme/catalog/refresh`, `GET /api/v1/theme/download` | Single web theme, 2026-09-26 | The web client now has one theme, Cinema Dark, which only the admin customizes with the accent color, token overrides and raw CSS. Profiles no longer pick or import themes, so nothing consumes a catalog of portable theme files, and the v2 catalog operations were removed before the v2 lock. The v1 routes are unchanged on the bridge surface, and `theme.catalog_url` keeps its default for them, until the retirement tombstone removes them with everything else. | +| Personal collection groups and per-profile allow lists on `/api/v1/collections`: `POST /api/v1/collections/groups`, `PUT /api/v1/collections/groups/order`, `PUT /api/v1/collections/groups/{id}` and `DELETE /api/v1/collections/groups/{id}` answer `501 unsupported`; `PUT /api/v1/collections/order` accepts only a null or absent `group_id`, and its `ordered_ids` names only the acting profile's own collections, so another profile's shared collection in the list is a `400`; `PUT /api/v1/collections/{id}` ignores a null or blank `group_id` and answers `501 unsupported` for a named one; `allowed_profile_ids` is ignored on create and update | Personal collection sharing, [#1615](https://github.com/Silo-Server/silo-server/issues/1615), [API](../collections-api.md) | Collections are either private to their creator or shared with every profile on the login, and each profile has one flat order of its own collections. The v1 routes share the service seams, so they follow the same model rather than keep a second one: list responses keep their shape, with `groups` always `[]`, `group_id` always `null`, and `allowed_profile_ids` reporting the effective audience. The group routes answer as they already did for an account on the SQLite user store. Keeping login-wide groups or per-profile allow lists on v1 would let one profile keep rearranging or narrowing another's collections, the defect #1615 removes. | +| The `sections.allow_profile_custom_sections` admin setting routes: `GET /api/v1/admin/settings/sections`, `PUT /api/v1/admin/settings/sections` | Rule rows for every profile, 2026-10-05 | Profiles may always add rule rows ("Titles matching rules") to their pages, so the "Let profiles add rule rows" switch and its setting are gone, and the v2 operations were removed before the v2 lock. The v1 routes stay on the bridge surface until the retirement tombstone: `GET` answers `allow_profile_custom_sections: true`, and `PUT` checks that the body is JSON, changes nothing, and answers the same. `GET /api/v1/profile/sections/flags` and v2 `getProfileSectionFlags` keep the field, always `true`. Only an admin adds new Editor's picks rows; a profile keeps the ones it has. | +| The meaning of `allow_profile_custom_sections` on `GET /api/v1/profile/sections/flags` | Rule rows for every profile, 2026-10-05 | The flag used to mean that a profile that is not an admin may also add rows of admin-only recipes (Editor's picks), and clients offered those rows on `role == "admin" \|\| flag`. It now answers `true` everywhere and no longer grants that: `PUT /api/v1/profile/sections` still refuses a new Editor's picks row from a profile that is not an admin with `403 custom_disabled` and its unchanged message. A client follows each recipe's `admin_only` in `listSectionRecipes` instead. No first-party client reads the flag. | | `PATCH /api/v1/playback/{session_id}/audio` | Playback protocol v3, [spec](playback-protocol-v3.md) | Superseded by the `track_change` replan operation, which changes the audio track *and* returns the resulting plan. The PATCH mutated the session without re-planning, so a track change that invalidated the route left the client playing a plan the server no longer agreed with. | | `409 protocol_disabled` on `POST /api/v1/playback/route-events`, and the `"enabled": false` shape of `GET /api/v1/playback/capability` | Playback protocol v3, [spec](playback-protocol-v3.md) | Both described a server with v3 switched off. With v3 the only playback protocol that state cannot exist — "disabled" would mean "no playback at all". The `enabled` field itself is kept and is constant `true`, so clients that feature-detect against it keep working; only the negative shape and the status code go. | | The draft-v3 platform-specific wire vocabulary: `ClientPlaybackContextV3.features`, `.platform`, and `.engines`; `PlanV3.engine`; `output_route_generation` in start, replan, output-context, and route-event bodies; Android device/build fields (`brand`, `device`, `product`, `soc_*`, `build_*`, `security_patch`, `sdk_int`, `abis`); and the `media3_only` / `detailed_decode_capabilities` feature tokens | Platform-neutral playback protocol v3, [spec](playback-protocol-v3.md) | These names exposed one client's implementation as the cross-platform contract. Before v1 lock they are replaced by neutral delivery classes, evidence tiers, `device.platform` / `device.os_version` / bounded `platform_details`, opaque `output_context_id`, and top-level feature advertisement. Carrying both drafts through lock would force every client to translate Media3-specific aliases indefinitely and leave two conflicting sources of capability truth. | diff --git a/docs/catalog-api.md b/docs/catalog-api.md index 5112f0078b..b961a1de99 100644 --- a/docs/catalog-api.md +++ b/docs/catalog-api.md @@ -59,6 +59,58 @@ the person without `prefetch` when the user actually opens them. the server accepts the parameter. Check it first: `/api/v2` rejects unknown query parameters, so an older server answers a prefetch read with `422`. +## Facet typeahead + +`GET /api/v2/catalog/filters/search` (`searchCatalogFacet`) searches the values of +one facet in a scope. The scope parameters are the ones `getCatalogFilters` takes: +`source`, `library_id`, `type` (media scope: `movie`, `series`, `episode`, and so +on), and the rest. `library_ids` names several libraries, one `library_ids` +parameter per id, and combines with `library_id`. Requested libraries are +intersected with the viewer's allowed libraries and stripped of disabled ones, +so the parameter can narrow a scope but never widen it; a scope left with no +library answers no values. `library_ids` is refused with `source=section`. +`getCatalogFilters` accepts it too. + +The answer carries two lists, each at most `limit` long. + +`matches` holds the values that start with `q`, ignoring case, in A to Z order, +and is empty for an empty `q`. `has_more` is true when more values than `limit` +start with `q`. This is the answer `matches` has always given, so a client that +reads only `matches` sees no change. + +`values` is the ranked answer, each entry with `count`, the number of titles in +the scope that carry the value. `values_has_more` is true when more values +matched than `limit`. For `genre`, `studio`, `network`, `country`, +`original_language`, and `content_rating`, a value matches when it starts with +`q` or when any word in it does; words are split on any character that is not a +letter or digit, so `bros` finds `Warner Bros. Pictures`. Values that start with +`q` come first, then word matches. Within each group, values with more titles +come first, then A to Z. An empty `q` returns the most common values. For +`author`, `narrator`, and `series`, `values` holds the names in `matches`, and an +empty `q` returns nothing. + +Matching ignores case, A to Z compares lowercased code points, and `%` and `_` +in `q` match literally. + +The server answers the first six facets from an in-memory list of the scope's +values, built with one query and kept for two minutes. The list is keyed by the +scope's full SQL and arguments, so viewers with different library access, +disabled libraries, or rating limits never share one. Each node keeps its own +lists, so `count` and newly added values can lag catalog changes by up to two +minutes. A node holds at most 500,000 values across all lists and drops the +least recently used list first, and builds at most four lists at once. A scope +with more distinct values than that is searched in SQL on every request, with +the same matching and order. + +`GET /api/v2/catalog/search/capabilities` advertises `facet_value_search: true` +when the server accepts `library_ids`, answers `values` and `values_has_more`, +matches word starts in `values`, and returns common values for an empty `q`. + +`GET /api/v1/catalog/filters/search` is frozen and answers as it always has: +names that start with `q`, read live from the database in `LOWER(name)` order +under the database collation, with `%` and `_` in `q` acting as wildcards. It +returns nothing for an empty `q`. + ## Saved browse sort `PUT /api/v2/collections/sort-preference` (`setCollectionSortPreference`) saves the @@ -110,12 +162,143 @@ Check it before saving a Watchlist or Favorites preference. The all, so it cannot be used to detect the personal-list kinds. The document supports `If-None-Match` and returns `304` when the caller's copy is current. +`login_sharing: true` reports that a personal collection is either private to its +creator or shared with every profile on the login, that `listCollections` includes other +profiles' shared collections, and that only the creator changes or orders a collection. Show +**Shared with me** and the single **Show to other profiles** switch only when it is true; see +[the personal collections API](collections-api.md). `groups` is always false. + The document's `import_sources` lists the sources a new imported collection can come from (`mdblist`, `tmdb`, `tmdb_list`); it is empty when `imports` is false. Check for `tmdb_list` before calling `importTMDBListCollection` (`POST /api/v2/collections/import/tmdb-list`), which follows a public TMDB list. The administrator capability document (`getAdminCollectionCapabilities`) carries the same field for `importAdminTMDBList`. +Both collection capability documents, `getCollectionCapabilities` and +`getAdminCollectionCapabilities`, also report: + +- `mdblist_search`: `true` when `searchMDBListLists` and `listTopMDBListLists` return + lists. It is `false` when the server has no MDBList API key; those operations then answer + `configured: false` with no lists. Importing a pasted MDBList link does not need the key. +- `schedule_time_zone`: the time zone the answering node runs cron collection schedules in. + `utc_offset` is the current offset from UTC as `±hh:mm` (for example `-05:00`), daylight + saving time included. `abbreviation` is the current abbreviation the node's time zone + database reports (for example `CDT`; some zones report a numeric form such as `-03`). + `name` is the IANA zone name (for example `America/Chicago`): the zone the node's `TZ` + environment variable names, or `UTC` when `TZ` is empty or names no known zone. It is + omitted when the node uses its system default zone or a `TZ` file path. Each node reports its own zone, and the + offset changes with daylight saving time, so read it with the schedule rather than + storing it. A cron `sync_schedule` with a `TZ=` or `CRON_TZ=` prefix runs in the zone + the prefix names instead. + +`getCollectionCapabilities` also reports `sync_schedule_editable`, `true` when `updateCollection` +accepts `sync_schedule`; see [Personal sync schedules](#personal-sync-schedules). + +`getCollectionCapabilities` also reports `contains_item`, `true` when `listCollections` accepts +`contains_item`; see [Collections that hold a title](#collections-that-hold-a-title). + +`getAdminCollectionCapabilities` also reports `section_references`, `true` when +`listAdminCollectionSections` is available and `listAdminCollections` items carry row counts; see +[Rows that show a server collection](#rows-that-show-a-server-collection). + +## Personal collection descriptions + +`createCollection` (`POST /api/v2/collections`) accepts an optional `description`, stored +with the new collection and returned as `description` on collection reads. Omitting it stores +an empty description; `null` is a validation failure. Check `create_description` in the +`getCollectionCapabilities` document before sending it: a server without that flag rejects the +member as unknown, and an account whose user store does not keep descriptions (the SQLite +store) reports `false` and answers a non-empty `description` with `501 capability_unsupported`. +`updateCollection` changes the description of an existing collection. + +The frozen `/api/v1/collections` create ignores a `description` member, in a JSON body and in +the multipart `data` field alike; the collection is created with an empty description. + +## Personal smart previews + +`previewCollection` (`POST /api/v2/collections/preview`) returns the first titles a smart query +matches within the acting profile's access. Each item carries `poster_url`, a card-size poster URL, +when the title has a poster, and omits the member when it has none. A missing `poster_url` alone +does not show whether the server returns posters: check `preview_posters` in the +`getCollectionCapabilities` document. The URL can be signed and expire, like other artwork URLs, +so read a fresh preview rather than storing it. +`previewAdminCollection` items carry the same field. + +The frozen `/api/v1/collections/preview` response is unchanged and carries no poster. + +## Personal collection imports + +A personal collection imported with `importMDBListCollection`, `importTMDBCollection`, +`importTMDBListCollection`, or `importTraktCollection` holds only titles its owner profile +can access: titles in the owner's allowed libraries that pass its rating limits. Those are +the `max_content_rating` ceiling, with the server's `access.unrated_content` setting for +unrated titles, and the `max_advisory_age` limit, with `require_advisory_age` (see +[Advisory-age limit](#advisory-age-limit)). The owner's hidden-library preference does not count; +it changes what the owner browses, not what the owner may access. Every sync applies this, +whether it runs on import, through `syncCollection`, or on the collection's schedule. + +- The item limit fills with titles the owner can access. The sync checks every source entry + it reads before it applies the limit, so titles outside the owner's access never take a + slot. With an item limit set, the sync reads at most four times the limit from the source + (100 to 500 entries), so an owner with tight limits can get fewer titles than the limit + even when the full source list holds enough the owner can access. +- `library_ids` keeps only titles in one of the listed libraries, and each title must still + be one the owner can access. Omit it to match against everything the owner can access. +- The sync summary counts only titles the owner can access. `items_matched` counts the + titles kept, and so does the `item_count` the sync stores, which the import response + returns. An entry the owner cannot access, or one outside `library_ids`, counts in + `items_unmatched`, like an entry the catalog lacks, so the summary never reveals titles + outside the owner's access. Collection reads such as `getCollection` and `listCollections` + report `item_count` as the members the reading profile can see, so the owner's count there + leaves out titles in libraries the owner hides. +- When the server cannot resolve the owner's access, the sync fails and the collection keeps + its existing members. The collection records `last_sync_status` `failed` and a + `last_sync_message` saying it was not updated, so a failed scheduled sync is visible too; + `syncCollection` answers with an error. An import in this state still creates the + collection, empty and with a failed first sync the caller can retry. + +Each viewer of a shared collection still sees only the titles that viewer can access. + +## Personal sync schedules + +`updateCollection` (`PATCH /api/v2/collections/{id}`) accepts `sync_schedule` on a synced list +(a collection imported from MDBList, TMDB, or Trakt). The value is a cadence name, `daily`, +`weekly`, or `monthly`, or `""` to stop scheduled syncs; `syncCollection` still syncs on demand. +Cron expressions are a validation failure for every account, administrators included, and so is +`sync_schedule` on a manual or smart collection or `null`. A cadence sets `next_sync_at` to the +schedule's next run; `""` sets `sync_schedule` to `""` and `next_sync_at` to `null`. Only the +collection's creator can change it, as with every other member. Check `sync_schedule_editable` +in the `getCollectionCapabilities` document before sending it: a server without that flag rejects +the member as unknown, and the flag is `false` when `imports` is. + +Collection reads carry `sync_cadence`, the cadence `sync_schedule` names: `daily`, `weekly`, +`monthly`, `""` when the collection is not synced, or `custom` for a stored schedule no cadence +name produces. Read it instead of matching cron expressions. + +A sync that is running when `sync_schedule` is saved, on any node, records its result but leaves +the `next_sync_at` the save set, even when the save kept the same cadence, and the collection a +sync or import returns shows what was stored. A scheduled sync runs on one node: it first moves +`next_sync_at` past the minimum interval, which is when a failed sync retries; a successful sync +replaces that with the schedule's next run unless the schedule was saved while it ran. + +The frozen `/api/v1/collections/{id}` update ignores a `sync_schedule` member, and `/api/v1` +collection responses carry no `sync_cadence`. + +## Collections that hold a title + +`listCollections` (`GET /api/v2/collections`) accepts an optional `contains_item`, a title's +content id. Each of the acting profile's own manual collections in the response then carries +`contains`: `true` when the collection holds the title, `false` when it does not. No other +collection carries the member: not a smart collection or synced list, and not a collection +another profile shares with the acting profile. Without `contains_item`, no collection carries +it. A title the acting profile cannot access, or one that does not exist, reports `false` on +every collection, even a collection that still stores it. Check `contains_item` in the +`getCollectionCapabilities` document before sending it: a server without that flag rejects the +parameter as unknown. + +The frozen `/api/v1/collections` list ignores a `contains_item` parameter, and its collections +carry no `contains`. + ## Library-scoped version lists `library_id` on `getCatalogItem`, `listCatalogItemVersions`, `listCatalogItemEpisodes`, @@ -396,6 +579,59 @@ titles through the existing viewer access filter; admin pages require acting administrator access. Membership identity, ordering and cursor revision checks are unchanged. Frozen v1 membership responses do not expose this field. +## Admin template list + +`listAdminCollectionTemplates` (`GET /api/v2/admin/collections/templates`) lists only the +templates with an `mdblist`, `tmdb` or `tmdb_list` source, the set a single collection can be +created from and the same set the personal template list (`listCollectionTemplates`) offers. `tmdb_discover` and +`tmdb_collection` templates are left out because only a template bundle can apply them; read +their summaries from the bundle list below. A category left with no templates is dropped; the +rest keep their order. The response schema is unchanged. + +The route requires acting administrator access. The frozen `/api/v1/admin/collections/templates` +response is unchanged and still lists every built-in template. + +## Template bundle summaries + +`listAdminCollectionTemplateBundles` (`GET /api/v2/admin/collections/template-bundles`) returns +each bundle with `templates`, a summary of every template in `template_ids` order: `id`, `title`, +`source`, `media_kind`, `featured`, `poster_path` (omitted when the template has no poster) and +`needs_setup`. The list covers every source a bundle uses, including `tmdb_discover` and +`tmdb_collection` templates, so a client can describe a bundle without the template catalog. +Check `template_summaries` in the `getAdminCollectionCapabilities` document before relying on +`templates`; a server without it returns bundles without summaries. + +`featured` is the pinned-first flag a collection created from the template starts with. +`needs_setup` is true for a template whose collection is created empty and cannot sync until an +administrator sets its source; today that is the `tmdb_franchise_placeholder` template, which has +no TMDB collection ID. Applying the bundle still creates that collection. + +The route requires acting administrator access. The frozen +`/api/v1/admin/collections/template-bundles` response is unchanged and carries no `templates`. + +## Rows that show a server collection + +`listAdminCollectionSections` (`GET /api/v2/admin/collections/{id}/sections`) lists the rows on +the administrator Home and library pages that show a server collection. Each item carries the +row's `id`, `scope` (`home` or `library`), `library_id` (the library whose page holds the row; +`null` for Home), `section_type`, `title`, `featured` (the row is its page's hero banner), +`enabled`, `position`, and `page_row_count`, the number of rows on that page. Home rows come +first, then library pages by library, each in page order. Rows a template bundle generated as a +hero are listed like any other row. An unknown collection answers `404`; a collection no row +shows answers an empty `items` list. + +Each `listAdminCollections` item carries `home_row_count`, the number of turned-on Home rows that +show the collection, and `row_count`, the number of Home and library page rows that show it, +turned-off rows included. One count covers the whole list. Both are absent from +`getAdminCollection` and every other response that returns one collection. + +The rows list includes turned-off rows. Both routes read the administrator page layouts only: rows a +profile added to its own Home are not listed or counted. A collection that `row_count` reports as +used cannot be deleted (`409`) until those rows stop showing it. + +Both routes require acting administrator access. The frozen `/api/v1/admin/collections` list is +unchanged and carries no counts. + ## Advisory age Movies and series may carry `advisory_age`, a recommended minimum viewer age from diff --git a/docs/collections-api.md b/docs/collections-api.md new file mode 100644 index 0000000000..5ddf6a4343 --- /dev/null +++ b/docs/collections-api.md @@ -0,0 +1,128 @@ +# Personal collections API + +> **API lifecycle:** this documents the stable `/api/v2` native contract, which locks with Silo +> 1.0. The frozen alpha `/api/v1` surface answers the same features through the pre-1.0 bridge +> window and is then retired. See [the native API contract](architecture/api-contract.md). + +A personal collection belongs to the profile that created it (`creator_profile_id`). Collections +for the whole server are admin collections and are not covered here. Paths below omit the +`/api/v2` prefix. + +## Who sees a collection + +- A private collection (`is_shared: false`) is visible only to its creator. +- A shared collection (`is_shared: true`) is visible, read-only, to every profile on the same + login, including profiles created later. The server decides this when it reads the + collection; there is no per-profile list. +- Nobody on another login ever sees a personal collection. + +A collection the profile cannot see answers `404 not_found` on every operation, reads and writes +alike. + +## Who changes a collection + +Only the creator changes a collection: its definition, members, artwork, sharing, sync, order +and deletion. Another profile's shared collection answers `403 permission_denied` to every +mutation, including `syncCollection`. The web client shows no management actions on collections +the profile does not own. + +## Sharing + +`is_shared` is the one sharing switch, on `createCollection`, `updateCollection` and the import +operations (`importMDBListCollection`, `importTMDBCollection`, `importTMDBListCollection`, +`importTraktCollection`). Clients label it **Show to other profiles**. + +`include_in_server_collections` is independent: it places the collection in the library +Collections tab (`listLibraryUserCollections`, `getLibraryCollections`) of every profile that can +see the collection. Cards there carry `creator_profile_id`, so clients can label another +profile's collection "by *Name*". + +## What a viewer sees in a shared collection + +The titles a profile sees are the collection's members limited to what the creator can access, +then limited again to what the viewer can access. Access means allowed libraries and rating +limits. A library the creator only hides from its own browsing does not count as a limit. The +creator's access is checked on every read. When it shrinks, titles disappear for every viewer +at once; stored manual members are hidden, not deleted, and return with the access. This applies +to `item_count`, item pages, the catalog `user_collection` source, home rows and the library +Collections tab. It never changes what a viewer can open elsewhere. + +Smart rules on watch state, favorites and the watchlist, and display filters, are evaluated for +the viewer. A shared collection never reveals its creator's activity. + +## Posters + +A collection's poster is an image its creator uploaded (`uploadCollectionPoster`), linked +(`poster_source_url`) or imported with a list, or otherwise a collage of its first titles' +posters, composed by the server. The collage follows the viewer: it shows the first titles +(up to four) that the viewer sees in the collection by the rules above, so another profile's +collage of a shared collection never shows a title that profile can't open. Members keep their +stored order; a smart collection uses its first matches in its query order. The display filter +does not narrow the collage. + +- `listCollections`, `getLibraryCollections` and `listLibraryUserCollections` return the + collage in `poster_url` and mark it with `poster_is_collage: true`. +- `getCollection` and `updateCollection` carry no `poster_url` for any poster, uploaded, + imported or collage, and `poster_is_collage` is false there: their body is the editor state + behind a strong `ETag`, and a presigned URL expires. Clients take the poster from the list. + `createCollection`, the imports and `uploadCollectionPoster` return an uploaded or imported + poster only; a new collection's collage is built after they answer. +- The server builds collages in the background, after a collection is created, edited or + synced, or its poster removed, and when a read finds one missing. Until then, and when no + title the viewer sees has a poster, `poster_url` is empty and clients show their + placeholder. +- `getCollectionCapabilities` reports `poster_collages: true` when the server shows collages. + It is false when the server has no artwork storage (and then `artwork` is false too), and for + an account on the SQLite user store. +- `deleteCollectionImage` removes an uploaded poster; the collection then shows its collage. + +The `/api/v1` bridge does not show collages: its personal collection and library tab reads keep +returning only uploaded and imported posters. + +## Listing and order + +`listCollections` returns the acting profile's own collections in its order, then the shared +collections of other profiles on the login, grouped by `creator_profile_id`, each in its +creator's order. Clients split the list with `creator_profile_id` and label shared collections +with the creator's name from the login's profile list. The web client orders the **Shared with +me** owners by the login's profile-list order. + +Each profile has one flat order of its own collections (`sort_order`). `getCollectionOrder` +returns it with a strong ETag, and `reorderCollections` replaces it under `If-Match`. +`ordered_ids` must name each of the acting profile's own collections exactly once. Any other ID, +including another profile's shared collection, is a `422 validation_failed` at +`body.ordered_ids`. The server stores no per-viewer order of shared collections. + +## Capabilities + +`getCollectionCapabilities` reports: + +- `login_sharing: true`: the model above. A client shows **Shared with me** and the single + sharing switch only when it is present and true. +- `poster_collages`: collections without an uploaded or imported poster show a collage (see + [Posters](#posters)). +- `groups: false`: personal collection groups are no longer supported. + +## Groups (being removed) + +Personal collection groups were removed by #1615. Until native clients stop decoding them, the +contract keeps their members with fixed values: + +- `listCollections` returns `groups: []`, and every collection has `group_id: null`. +- `createCollectionGroup`, `getCollectionGroup`, `updateCollectionGroup`, + `deleteCollectionGroup`, `getCollectionGroupsOrder` and `reorderCollectionGroups` answer + `501 capability_unsupported`, as they already did for an account on the SQLite user store. +- Setting `group_id` to a group in `updateCollection` answers `501 capability_unsupported`; a + null or blank `group_id` is accepted and changes nothing, since no collection is in a group. +- `group_id` on `reorderCollections` and `getCollectionOrder` must be omitted (or null in the + body); a value is a `422 validation_failed`. + +A later change removes these operations and members from `/api/v2` before the 1.0 lock. + +## `/api/v1` bridge + +The frozen v1 routes share the same rules. `GET /api/v1/collections` returns own and shared +collections; `groups` is always `[]` and `group_id` always `null`. `allowed_profile_ids` reports +the effective audience (the creator alone, or every profile on the login) and is ignored on +input. `PUT /api/v1/collections/order` accepts only a null or absent `group_id`, and its +`ordered_ids` names only the acting profile's own collections. The v1 group routes answer `501`. See the [v1 removals table](architecture/v1-scope.md#breaking-removals-taken-before-lock). diff --git a/internal/access/resolver.go b/internal/access/resolver.go index 2a4319f34e..93b495ff14 100644 --- a/internal/access/resolver.go +++ b/internal/access/resolver.go @@ -122,7 +122,7 @@ func (r *Resolver) Resolve(ctx context.Context, input ResolveInput) (Scope, erro // Apply the profile's disabled library IDs setting. disabled := preferences.DisabledLibraryIDs - if len(disabled) > 0 { + if len(disabled) > 0 && !input.ContentAccessOnly { if scope.AllowedLibraryIDs != nil { // Restricted user: subtract disabled IDs from the allowed set, // remembering which allowed ones the profile hid. diff --git a/internal/access/resolver_test.go b/internal/access/resolver_test.go index 90861bd538..0cca5378f3 100644 --- a/internal/access/resolver_test.go +++ b/internal/access/resolver_test.go @@ -210,7 +210,7 @@ func (s stubStore) ReplaceCollectionItems(context.Context, string, []userstore.C func (s stubStore) ReorderCollectionItems(context.Context, string, []string) error { panic("unused") } -func (s stubStore) ReorderCollections(context.Context, string, *string, []string) error { +func (s stubStore) ReorderCollections(context.Context, string, []string) error { panic("unused") } func (s stubStore) UpdateCollectionSyncState(context.Context, userstore.UpdateCollectionSyncStateInput) error { @@ -833,3 +833,50 @@ func (p stubGroupProvider) GetPolicyForUser(context.Context, int) (*GroupPolicy, func (s stubStore) LatestHistoryIDs(context.Context, string, map[string][]string) (map[string]string, error) { return nil, nil } + +// A content-only resolve answers what a profile may access, not what it +// browses: the profile's hidden libraries are a browsing preference, so they +// neither shrink a restricted allow list nor ride along as disabled libraries. +func TestResolver_ContentAccessOnlyIgnoresHiddenLibraries(t *testing.T) { + hidden := []userstore.SettingValue{{ + SettingIdentity: userstore.SettingIdentity{ + Key: settingskeys.UiDisabledLibraryIds, + Scope: settingscontract.ScopeProfile, + ProfileID: "prof-1", + }, + Value: json.RawMessage(`[2,4]`), + }} + cases := []struct { + name string + libraries []int + wantAllowed []int + }{ + {"unrestricted account", nil, nil}, + {"restricted account", []int{1, 2, 3, 4}, []int{1, 2, 3, 4}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resolver := NewResolver( + stubUserRepo{user: &models.User{ID: 1, LibraryIDs: tc.libraries, AccessPolicyRevision: 5}}, + stubStoreProvider{store: stubStore{ + profile: &userstore.Profile{ID: "prof-1", MaxContentRating: "PG", PINHash: "locked"}, + settingValues: hidden, + }}, + nil, + ) + scope, err := resolver.Resolve(context.Background(), ResolveInput{UserID: 1, ProfileID: "prof-1", SkipPINVerification: true, ContentAccessOnly: true}) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + if !reflect.DeepEqual(scope.AllowedLibraryIDs, tc.wantAllowed) { + t.Fatalf("AllowedLibraryIDs = %#v, want %#v", scope.AllowedLibraryIDs, tc.wantAllowed) + } + if scope.DisabledLibraryIDs != nil { + t.Fatalf("DisabledLibraryIDs = %v, want none", scope.DisabledLibraryIDs) + } + if scope.MaxContentRating != "PG" { + t.Fatalf("MaxContentRating = %q, want the profile's ceiling", scope.MaxContentRating) + } + }) + } +} diff --git a/internal/access/types.go b/internal/access/types.go index 0196f23917..b5491e49b4 100644 --- a/internal/access/types.go +++ b/internal/access/types.go @@ -120,6 +120,11 @@ type ResolveInput struct { ProfileID string ProfileToken string SkipPINVerification bool + // ContentAccessOnly resolves what the profile may access, leaving out + // the libraries it hides from its own browsing (ui.disabled_library_ids). + // Reading another profile's shared personal collection uses it: the + // owner's browsing preference does not limit what the collection shows. + ContentAccessOnly bool } // ProfileTokenClaims are the claims embedded in a verified profile token. diff --git a/internal/api/handlers/admin_collection_sections_db_test.go b/internal/api/handlers/admin_collection_sections_db_test.go new file mode 100644 index 0000000000..98513b11c2 --- /dev/null +++ b/internal/api/handlers/admin_collection_sections_db_test.go @@ -0,0 +1,120 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "slices" + "testing" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/sections" +) + +// TestAdminCollectionRowsDB checks the rows that show a server collection +// through the handler: the rows list and the grouped counts agree, an unknown +// collection is not found, and the frozen /api/v1 admin list stays +// byte-identical when rows start to show the collection. +func TestAdminCollectionRowsDB(t *testing.T) { + f := newPagingIntegrationFixture(t) + sectionRepo := sections.NewRepository(f.pool) + h := NewLibraryCollectionHandler(catalog.NewLibraryCollectionRepository(f.pool), nil, catalog.NewItemRepository(f.pool), nil) + h.SectionRepo = sectionRepo + shown, err := h.CreateAdminCollection(t.Context(), AdminCollectionCreate{LibraryID: f.library, Title: "Shown", CollectionType: "manual"}) + if err != nil { + t.Fatal(err) + } + unused, err := h.CreateAdminCollection(t.Context(), AdminCollectionCreate{LibraryID: f.library, Title: "Unused", CollectionType: "manual"}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, ids := context.Background(), []string{shown.ID, unused.ID} + _, _ = f.pool.Exec(ctx, `DELETE FROM library_collections WHERE id = ANY($1)`, ids) + _, _ = f.pool.Exec(ctx, `DELETE FROM library_collection_revisions WHERE collection_id = ANY($1)`, ids) + }) + + v1List := func() []byte { + t.Helper() + rec := httptest.NewRecorder() + h.HandleListAdminCollections(rec, httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/admin/collections?library_id=%d", f.library), nil)) + if rec.Code != http.StatusOK { + t.Fatalf("v1 list: %d %s", rec.Code, rec.Body) + } + return rec.Body.Bytes() + } + before := v1List() + + config := json.RawMessage(fmt.Sprintf(`{"library_collection_id":%q}`, shown.ID)) + var rowIDs []string + t.Cleanup(func() { + _, _ = f.pool.Exec(context.Background(), `DELETE FROM page_sections WHERE id = ANY($1)`, rowIDs) + }) + for _, row := range []*sections.PageSection{ + {Scope: "home", Position: 900, SectionType: sections.SectionCollection, Title: "On Home", Config: config, Enabled: true}, + {Scope: "library", LibraryID: &f.library, SectionType: sections.SectionCollection, Title: "On the library page", Config: config, Enabled: false}, + } { + created, err := sectionRepo.Create(t.Context(), row) + if err != nil { + t.Fatal(err) + } + rowIDs = append(rowIDs, created.ID) + } + + after := v1List() + if string(after) != string(before) { + t.Fatalf("v1 admin list changed when rows started to show a collection:\nbefore %s\nafter %s", before, after) + } + var v1 struct { + Collections []map[string]json.RawMessage `json:"collections"` + } + if err := json.Unmarshal(after, &v1); err != nil { + t.Fatal(err) + } + if len(v1.Collections) != 2 { + t.Fatalf("v1 list has %d collections, want 2: %s", len(v1.Collections), after) + } + for _, c := range v1.Collections { + for _, key := range []string{"home_row_count", "row_count"} { + if _, ok := c[key]; ok { + t.Fatalf("v1 admin list carries %s: %s", key, after) + } + } + } + + refs, err := h.AdminCollectionSections(t.Context(), shown.ID) + if err != nil { + t.Fatal(err) + } + ids := make([]string, 0, len(refs)) + for _, r := range refs { + ids = append(ids, r.ID) + } + if !slices.Equal(ids, rowIDs) || refs[1].PageRowCount != 1 || refs[1].Enabled { + t.Fatalf("rows = %+v, want the Home row then the turned-off library row", refs) + } + counts, err := h.AdminCollectionRowCounts(t.Context(), []string{shown.ID, unused.ID}) + if err != nil { + t.Fatal(err) + } + if got := counts[shown.ID]; got != (AdminCollectionRowCount{Home: 1, Total: 2}) { + t.Fatalf("counts for the shown collection = %+v, want 1 on Home of 2", got) + } + if _, ok := counts[unused.ID]; ok { + t.Fatalf("an unused collection has counts: %+v", counts) + } + if empty, err := h.AdminCollectionSections(t.Context(), unused.ID); err != nil || len(empty) != 0 { + t.Fatalf("unused collection rows = %+v, %v", empty, err) + } + + if _, err := h.AdminCollectionSections(t.Context(), "missing-collection"); !errors.Is(err, catalog.ErrLibraryCollectionNotFound) { + t.Fatalf("unknown collection: %v, want not found", err) + } + h.SectionRepo = nil + if counts, err := h.AdminCollectionRowCounts(t.Context(), []string{shown.ID}); err != nil || counts != nil { + t.Fatalf("counts without sections = %v, %v; want none", counts, err) + } +} diff --git a/internal/api/handlers/admin_collection_sections_service.go b/internal/api/handlers/admin_collection_sections_service.go new file mode 100644 index 0000000000..81dc68f807 --- /dev/null +++ b/internal/api/handlers/admin_collection_sections_service.go @@ -0,0 +1,39 @@ +package handlers + +import ( + "context" + "net/http" + + "github.com/Silo-Server/silo-server/internal/sections" +) + +// AdminCollectionSection is an admin Home or library page row that shows a +// server collection. +type AdminCollectionSection = sections.LibraryCollectionReference + +// AdminCollectionRowCount counts the admin page rows that show one server +// collection. +type AdminCollectionRowCount = sections.LibraryCollectionReferenceCount + +// AdminCollectionSections lists the admin page rows that show a server +// collection. Rows profiles added to their own Home are not stored in +// page_sections and are not listed. +func (h *LibraryCollectionHandler) AdminCollectionSections(ctx context.Context, id string) ([]AdminCollectionSection, error) { + if h.SectionRepo == nil { + return nil, apiError(http.StatusServiceUnavailable, "service_unavailable", "Section references are not configured") + } + if _, err := h.repo.CollectionRevision(ctx, id); err != nil { + return nil, err + } + return h.SectionRepo.ListLibraryCollectionReferences(ctx, id) +} + +// AdminCollectionRowCounts counts, in one query, the admin page rows that show +// each of the given server collections. It returns nil when sections are not +// configured, so callers leave the counts out rather than report zero. +func (h *LibraryCollectionHandler) AdminCollectionRowCounts(ctx context.Context, ids []string) (map[string]AdminCollectionRowCount, error) { + if h.SectionRepo == nil { + return nil, nil + } + return h.SectionRepo.CountLibraryCollectionReferencesByID(ctx, ids) +} diff --git a/internal/api/handlers/admin_collections_service.go b/internal/api/handlers/admin_collections_service.go index 58220a3bca..28560bcbb1 100644 --- a/internal/api/handlers/admin_collections_service.go +++ b/internal/api/handlers/admin_collections_service.go @@ -557,8 +557,8 @@ func (h *LibraryCollectionHandler) SyncAdminCollection(ctx context.Context, id s } return h.service.SyncCollection(ctx, id) } -func (h *LibraryCollectionHandler) ListAdminCollectionTemplates(ctx context.Context) (templates.BundleCatalog, error) { - return h.templateRegistry().BundleCatalog(), nil +func (h *LibraryCollectionHandler) ListAdminCollectionTemplates(ctx context.Context) ([]templates.BundleWithTemplates, error) { + return h.templateRegistry().BundlesWithTemplates(), nil } func (h *LibraryCollectionHandler) ApplyAdminCollectionTemplate(ctx context.Context, id string, req AdminCollectionTemplateApply) (AdminCollectionTemplateResult, error) { result, err := h.applyTemplateBundle(ctx, id, req, nil) diff --git a/internal/api/handlers/admin_collections_service_test.go b/internal/api/handlers/admin_collections_service_test.go index 54a53f24b1..e74ce8f597 100644 --- a/internal/api/handlers/admin_collections_service_test.go +++ b/internal/api/handlers/admin_collections_service_test.go @@ -90,6 +90,24 @@ func TestLegacyTraktAdminCollectionLibraryScopeIsImmutable(t *testing.T) { } } +// The web editor changes a franchise list's TMDB collection ID by sending a +// rebuilt tmdb_collection source; only Trakt sources are frozen. +func TestAdminCollectionSourceUpdateAcceptsFranchiseID(t *testing.T) { + existing := &models.LibraryCollection{ + CollectionType: "tmdb", + LibraryIDs: []int{1}, + SourceURL: "tmdb://collection/10", + SourceConfig: json.RawMessage(`{"mode":"tmdb_collection","collection_id":10}`), + } + err := validateAdminCollectionSourceUpdate(existing, AdminCollectionUpdate{ + SourceURL: new("tmdb://collection/119"), + SourceConfig: json.RawMessage(`{"mode":"tmdb_collection","collection_id":119,"limit":20}`), + }) + if err != nil { + t.Fatalf("validateAdminCollectionSourceUpdate: %v", err) + } +} + func TestAdminCollectionServiceCanonicalGuardAndMembershipDB(t *testing.T) { f := newPagingIntegrationFixture(t) repo := catalog.NewLibraryCollectionRepository(f.pool) diff --git a/internal/api/handlers/admin_sections_service.go b/internal/api/handlers/admin_sections_service.go index 2abdaf32fa..9611e16ae2 100644 --- a/internal/api/handlers/admin_sections_service.go +++ b/internal/api/handlers/admin_sections_service.go @@ -6,10 +6,12 @@ import ( "errors" "log/slog" "net/http" + "slices" "strings" apimw "github.com/Silo-Server/silo-server/internal/api/middleware" "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/imagesize" "github.com/Silo-Server/silo-server/internal/models" "github.com/Silo-Server/silo-server/internal/sections" "github.com/Silo-Server/silo-server/internal/sections/recipes" @@ -28,7 +30,14 @@ type AdminSectionRestore = restoreDefaultsRequest type AdminSectionBulkCreate = bulkCreateSectionRequest type AdminSectionBulkResult = bulkCreateSectionResponse type AdminSectionPreviewRequest = previewRequest -type AdminSectionPreviewResult = previewResponse + +// AdminSectionPreviewResult is a preview sample plus each item's presigned +// poster URL keyed by content ID. Items without a poster have no entry. +type AdminSectionPreviewResult struct { + Items []*models.MediaItem + TotalCount int + PosterURLs map[string]string +} type adminSectionRevisionKey struct{} func WithAdminSectionExpectedRevision(ctx context.Context, revision int64) context.Context { @@ -81,6 +90,11 @@ func (h *SectionHandler) CreateAdminSection(ctx context.Context, req AdminSectio if msg, ok := validateSectionConfig(sections.SectionType(req.SectionType), req.Config); !ok { return none, apiError(http.StatusBadRequest, "bad_request", msg) } + if req.ValidateRecipe { + if msg, ok := validateRecipeConfig(sections.SectionType(req.SectionType), req.Config); !ok { + return none, apiError(http.StatusBadRequest, "bad_request", msg) + } + } sec := §ions.PageSection{ Scope: scope, @@ -242,10 +256,48 @@ func (h *SectionBulkHandler) BulkCreateAdminSections(ctx context.Context, req Ad } func (h *SectionHandler) PreviewAdminSection(ctx context.Context, req AdminSectionPreviewRequest) (AdminSectionPreviewResult, error) { - return h.previewAdminSection(ctx, req, AccessFilterFromContext(ctx, "")) + preview, err := h.previewAdminSection(ctx, req, AccessFilterFromContext(ctx, "")) + if err != nil { + return AdminSectionPreviewResult{}, err + } + return AdminSectionPreviewResult{Items: preview.Items, TotalCount: preview.TotalCount, PosterURLs: h.previewPosterURLs(ctx, preview.Items)}, nil +} + +// previewPosterURLs presigns each item's poster at the variant a live row +// serves by default, so a preview shows the image the saved row will show. +func (h *SectionHandler) previewPosterURLs(ctx context.Context, items []*models.MediaItem) map[string]string { + if h.DetailSvc == nil { + return nil + } + posterPaths := make(map[string]string, len(items)) + paths := make([]string, 0, len(items)) + for _, item := range items { + if item == nil { + continue + } + path := sizedPosterPath(item.PosterPath, imagesize.Unset) + if path == "" || path == "-" { + continue + } + posterPaths[item.ContentID] = path + if !slices.Contains(paths, path) { + paths = append(paths, path) + } + } + resolved := h.DetailSvc.PresignURLsWithExpiry(ctx, paths, requestVariantHint("featured", imagesize.Unset)) + out := make(map[string]string, len(posterPaths)) + for contentID, path := range posterPaths { + if url := resolved[path].URL; url != "" { + out[contentID] = url + } + } + return out } -func (h *SectionHandler) previewAdminSection(ctx context.Context, req AdminSectionPreviewRequest, filter catalog.AccessFilter) (AdminSectionPreviewResult, error) { - var none AdminSectionPreviewResult + +// previewAdminSection is shared with the frozen /api/v1 preview, which +// serializes previewResponse as is. +func (h *SectionHandler) previewAdminSection(ctx context.Context, req AdminSectionPreviewRequest, filter catalog.AccessFilter) (previewResponse, error) { + var none previewResponse rec, ok := recipes.Get(req.SectionType) if !ok { return none, apiError(http.StatusBadRequest, "unknown_type", "section_type not registered") @@ -413,11 +465,20 @@ func (h *SectionHandler) UpdateAdminSection(ctx context.Context, id string, req if msg, ok := validateSectionConfig(existing.SectionType, existing.Config); !ok { return none, apiError(400, "bad_request", msg) } + definitionChanged := existing.SectionType != originalType || !jsonConfigEqual(originalConfig, existing.Config) + // Only a changed type or config runs the recipe check, so a row saved + // before create ran it can still be moved, renamed or disabled, even + // when the client echoes its unchanged type and config. + if req.ValidateRecipe && definitionChanged { + if msg, ok := validateRecipeConfig(existing.SectionType, existing.Config); !ok { + return none, apiError(400, "bad_request", msg) + } + } willBeTraktBacked := isTraktBackedSection(string(existing.SectionType), existing.Config) if !wasTraktBacked && willBeTraktBacked { return none, apiError(http.StatusBadRequest, "unsupported_source", "new Trakt-backed sections are not supported") } - if wasTraktBacked && (existing.SectionType != originalType || !jsonConfigEqual(originalConfig, existing.Config) || (!originalEnabled && existing.Enabled)) { + if wasTraktBacked && (definitionChanged || (!originalEnabled && existing.Enabled)) { return none, apiError(http.StatusBadRequest, "legacy_source_immutable", "legacy Trakt section sources cannot be changed or reactivated") } diff --git a/internal/api/handlers/admin_sections_service_test.go b/internal/api/handlers/admin_sections_service_test.go index 3b17f4c71f..78bd2ce044 100644 --- a/internal/api/handlers/admin_sections_service_test.go +++ b/internal/api/handlers/admin_sections_service_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "net/http" "reflect" "strings" "testing" @@ -150,3 +151,71 @@ func TestAdminSectionWildcardPatchRemergesAfterConcurrentWriteDB(t *testing.T) { t.Fatalf("wildcard replay lost concurrent unmentioned fields: %+v", updated) } } + +func TestAdminSectionCreateRejectsEmptyEditorsPicks(t *testing.T) { + _, err := (&SectionHandler{}).CreateAdminSection(t.Context(), AdminSectionCreate{ + Title: "Editor's Picks", SectionType: string(sections.SectionAdminCuratedList), + Config: json.RawMessage(`{"item_ids":[]}`), ValidateRecipe: true, + }) + apiErr, ok := errors.AsType[*APIError](err) + if !ok || apiErr.Status != http.StatusBadRequest || !strings.Contains(apiErr.Message, "item_ids") { + t.Fatalf("error = %#v, want a 400 naming item_ids", err) + } +} + +func TestAdminSectionUpdateValidatesRecipeConfigDB(t *testing.T) { + f := newPagingIntegrationFixture(t) + repo := sections.NewRepository(f.pool) + h := NewSectionHandler(repo, nil) + t.Cleanup(func() { + _, _ = f.pool.Exec(context.Background(), `DELETE FROM page_sections WHERE library_id=$1`, f.library) + }) + picks, err := h.CreateAdminSection(t.Context(), AdminSectionCreate{Scope: "library", LibraryID: &f.library, Title: "Picks", SectionType: string(sections.SectionAdminCuratedList), Enabled: true, Config: json.RawMessage(`{"item_ids":["a"]}`), ValidateRecipe: true}) + if err != nil { + t.Fatal(err) + } + _, err = h.UpdateAdminSection(t.Context(), picks.ID, AdminSectionUpdate{Config: json.RawMessage(`{"item_ids":[]}`), ValidateRecipe: true}) + if apiErr, ok := errors.AsType[*APIError](err); !ok || apiErr.Status != http.StatusBadRequest { + t.Fatalf("emptying Editor's Picks: error = %#v, want 400", err) + } + stored, err := repo.GetByID(t.Context(), picks.ID) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(stored.Config), `"a"`) { + t.Fatalf("rejected update changed the row: %s", stored.Config) + } + + // A row saved before create validated its recipe can still be switched off. + legacy, err := repo.Create(t.Context(), §ions.PageSection{Scope: "library", LibraryID: &f.library, SectionType: sections.SectionAdminCuratedList, Title: "Empty picks", ItemLimit: 20, Config: json.RawMessage(`{"item_ids":[]}`), Enabled: true}) + if err != nil { + t.Fatal(err) + } + // The admin editor echoes the unchanged type and config with every save. + updated, err := h.UpdateAdminSection(t.Context(), legacy.ID, AdminSectionUpdate{ + Enabled: new(false), SectionType: string(legacy.SectionType), Config: json.RawMessage(`{"item_ids": []}`), ValidateRecipe: true, + }) + if err != nil { + t.Fatalf("disabling an existing row with its unchanged config echoed: %v", err) + } + if updated.Enabled { + t.Fatalf("row stayed enabled: %+v", updated) + } +} + +// The frozen /api/v1 routes don't request the recipe check, so they keep +// storing what they stored before it existed. +func TestAdminSectionV1WritesSkipRecipeConfigDB(t *testing.T) { + f := newPagingIntegrationFixture(t) + h := NewSectionHandler(sections.NewRepository(f.pool), nil) + t.Cleanup(func() { + _, _ = f.pool.Exec(context.Background(), `DELETE FROM page_sections WHERE library_id=$1`, f.library) + }) + empty, err := h.CreateAdminSection(t.Context(), AdminSectionCreate{Scope: "library", LibraryID: &f.library, Title: "Picks", SectionType: string(sections.SectionAdminCuratedList), Enabled: true, Config: json.RawMessage(`{"item_ids":[]}`)}) + if err != nil { + t.Fatalf("v1 create of an empty Editor's Picks row: %v", err) + } + if _, err := h.UpdateAdminSection(t.Context(), empty.ID, AdminSectionUpdate{Config: json.RawMessage(`{"item_ids":[]}`), Title: "Still empty"}); err != nil { + t.Fatalf("v1 update of an empty Editor's Picks row: %v", err) + } +} diff --git a/internal/api/handlers/catalog.go b/internal/api/handlers/catalog.go index a01ecff50c..b305831de5 100644 --- a/internal/api/handlers/catalog.go +++ b/internal/api/handlers/catalog.go @@ -477,8 +477,8 @@ func (h *CatalogHandler) HandleGetCatalogFilters(w http.ResponseWriter, r *http. writeJSON(w, http.StatusOK, view) } -// catalogFacetSearchResponse mirrors catalog.CatalogFacetSearchResult on -// the wire. matches[] is always present (empty when no hits); has_more +// catalogFacetSearchResponse is the v1 facet typeahead answer. +// matches[] is always present (empty when no hits); has_more // is true when the underlying result set held more entries than the // requested limit. type catalogFacetSearchResponse struct { @@ -530,12 +530,12 @@ func (h *CatalogHandler) HandleGetCatalogFacetSearch(w http.ResponseWriter, r *h return } - view, err := h.SearchFacet(r.Context(), viewerFromRequest(r, accessFilter), req, facet, prefix, limit) + matches, hasMore, err := h.resolver.SearchFacetV1(r.Context(), req, accessFilter, facet, prefix, limit) if err != nil { - writeAPIError(w, err) + writeAPIError(w, facetSearchError(err)) return } - writeJSON(w, http.StatusOK, view) + writeJSON(w, http.StatusOK, catalogFacetSearchResponse{Matches: matches, HasMore: hasMore}) } func parseIncludeTechnical(raw string) bool { diff --git a/internal/api/handlers/catalog_items_service.go b/internal/api/handlers/catalog_items_service.go index 411f226bcb..7cff50a72e 100644 --- a/internal/api/handlers/catalog_items_service.go +++ b/internal/api/handlers/catalog_items_service.go @@ -38,8 +38,9 @@ type SortMetricsView = sortMetricsResponse // CatalogFiltersView is the facet document of a catalog scope. type CatalogFiltersView = catalogFiltersResponse -// CatalogFacetSearchView is a facet typeahead answer. -type CatalogFacetSearchView = catalogFacetSearchResponse +// CatalogFacetSearchView is a v2 facet typeahead answer; see +// catalog.CatalogFacetSearchResult for what each field holds. +type CatalogFacetSearchView = catalog.CatalogFacetSearchResult // CatalogQueryView is one page of the JSON-body catalog query. type CatalogQueryView = browseResponse @@ -194,24 +195,25 @@ func (h *CatalogHandler) Filters(ctx context.Context, v ItemViewer, req catalog. return view, nil } -// SearchFacet answers a prefix typeahead over one filter facet. limit is +// SearchFacet answers a v2 typeahead over one filter facet. limit is // already validated and clamped by the caller. -func (h *CatalogHandler) SearchFacet(ctx context.Context, v ItemViewer, req catalog.CatalogRequest, facet, prefix string, limit int) (CatalogFacetSearchView, error) { +func (h *CatalogHandler) SearchFacet(ctx context.Context, v ItemViewer, req catalog.CatalogRequest, facet, q string, limit int) (CatalogFacetSearchView, error) { if h == nil || h.resolver == nil || h.itemsH == nil { return CatalogFacetSearchView{}, apiError(http.StatusInternalServerError, "internal_error", "Catalog is not configured") } - result, err := h.resolver.SearchFacet(ctx, req, v.Access, facet, prefix, limit) + result, err := h.resolver.SearchFacet(ctx, req, v.Access, facet, q, limit) if err != nil { - if errors.Is(err, catalog.ErrInvalidCatalogRequest) { - return CatalogFacetSearchView{}, apiError(http.StatusBadRequest, "bad_request", err.Error()) - } - return CatalogFacetSearchView{}, apiError(http.StatusInternalServerError, "internal_error", "Failed to search catalog facet") + return CatalogFacetSearchView{}, facetSearchError(err) } - matches := result.Matches - if matches == nil { - matches = []string{} + return *result, nil +} + +// facetSearchError maps a facet typeahead failure to its API error. +func facetSearchError(err error) error { + if errors.Is(err, catalog.ErrInvalidCatalogRequest) { + return apiError(http.StatusBadRequest, "bad_request", err.Error()) } - return CatalogFacetSearchView{Matches: matches, HasMore: result.HasMore}, nil + return apiError(http.StatusInternalServerError, "internal_error", "Failed to search catalog facet") } // QueryItems answers one page of the JSON-body catalog query. The limit is diff --git a/internal/api/handlers/collection_artwork.go b/internal/api/handlers/collection_artwork.go index de368e07f0..8783463972 100644 --- a/internal/api/handlers/collection_artwork.go +++ b/internal/api/handlers/collection_artwork.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "fmt" "io" "io/fs" @@ -16,6 +17,7 @@ import ( "github.com/Silo-Server/silo-server/internal/artworkkey" "github.com/Silo-Server/silo-server/internal/blobstore" "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/collage" "github.com/Silo-Server/silo-server/internal/imageutil" ) @@ -328,3 +330,101 @@ func removeCollectionImageVariants( } return nil } + +// collectionCollageComposer composes collection collages from title posters +// and stores them under one artwork prefix (catalog.CollageGenerator). Each +// collage is stored in the collection's collage directory with its key as the +// revision, so every node that builds the same collage writes the same +// objects. +type collectionCollageComposer struct { + prefix string + store blobstore.Store + posters itemPosterSigner + httpClient *http.Client +} + +// NewPersonalCollectionCollageGenerator composes personal collection +// collages and stores them beside their uploaded posters. It returns nil +// when artwork storage or poster signing is not configured, which leaves +// personal collections without collages. +func NewPersonalCollectionCollageGenerator(store blobstore.Store, posters itemPosterSigner, httpClient *http.Client) catalog.CollageGenerator { + if store == nil || posters == nil { + return nil + } + return collectionCollageComposer{prefix: userCollectionImagePrefix, store: store, posters: posters, httpClient: httpClient} +} + +// ComposeCollectionCollage fetches the source posters, composes them and +// stores the result. +func (c collectionCollageComposer) ComposeCollectionCollage(ctx context.Context, collectionID, key string, sources []string) (string, string, error) { + if len(sources) == 0 { + return "", "", collage.ErrNotEnoughImages + } + + slog.InfoContext(ctx, "collage: generating poster", "component", "api", "collection_id", collectionID, "item_poster_count", len(sources)) + + // Resolve poster paths to fetchable URLs. The collage is stored under the + // key of all its sources, so a source that doesn't resolve or download + // fails the build rather than being left out. A later read retries it. + resolved := c.posters.PresignImageURLs(ctx, sources, "poster", "small") + imageData := make([][]byte, 0, len(sources)) + for _, path := range sources { + imageURL := resolved[path] + if imageURL == "" { + return "", "", fmt.Errorf("collage source %q did not resolve", path) + } + data, err := c.fetchImage(ctx, imageURL) + if err != nil { + // A transport error names the presigned URL; keep it out of logs. + if urlErr, ok := errors.AsType[*url.Error](err); ok { + err = urlErr.Err + } + return "", "", fmt.Errorf("fetching collage source %q: %w", path, err) + } + imageData = append(imageData, data) + } + + composited, err := collage.ComposePoster(imageData) + if err != nil { + return "", "", err + } + + // Process through the standard image pipeline (generates WebP variants + thumbhash). + s3Path, thumbhash, err := putCollectionImageVariants(ctx, c.store, collectionCollageDir(c.prefix, collectionID), key, collectionPosterWidths, composited) + if err != nil { + return "", "", fmt.Errorf("processing collage image: %w", err) + } + if want := c.CollectionCollagePath(collectionID, key); s3Path != want { + return "", "", fmt.Errorf("collage stored at %q, want %q", s3Path, want) + } + + slog.InfoContext(ctx, "collage: poster generated successfully", "component", "api", "collection_id", collectionID, "s3_path", s3Path) + return s3Path, thumbhash, nil +} + +// CollectionCollagePath returns the path ComposeCollectionCollage stores the +// collection's collage key under. +func (c collectionCollageComposer) CollectionCollagePath(collectionID, key string) string { + return artworkkey.Original(collectionCollageDir(c.prefix, collectionID), key, ".webp") +} + +// fetchImage downloads an image from a resolved URL. +func (c collectionCollageComposer) fetchImage(ctx context.Context, imageURL string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil) + if err != nil { + return nil, err + } + client := c.httpClient + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("image fetch returned status %d", resp.StatusCode) + } + return io.ReadAll(io.LimitReader(resp.Body, collectionImageMaxBytes)) +} diff --git a/internal/api/handlers/collection_templates_test.go b/internal/api/handlers/collection_templates_test.go index cd6d843d10..c6af365a16 100644 --- a/internal/api/handlers/collection_templates_test.go +++ b/internal/api/handlers/collection_templates_test.go @@ -1,6 +1,7 @@ package handlers import ( + "bytes" "encoding/json" "net/http" "net/http/httptest" @@ -36,6 +37,29 @@ func TestCollectionTemplateHandlerReturnsBuiltinCatalog(t *testing.T) { } } +// TestV1TemplateListKeepsEveryTemplate pins /api/v1 to the full built-in +// catalog. The /api/v2 admin list leaves out templates no import route accepts; +// the frozen v1 body still carries TMDB Discover and franchise templates. +func TestV1TemplateListKeepsEveryTemplate(t *testing.T) { + full := templates.CatalogDefault() + var want bytes.Buffer + if err := json.NewEncoder(&want).Encode(full); err != nil { + t.Fatal(err) + } + if !bytes.Contains(want.Bytes(), []byte(`"source":"tmdb_discover"`)) || !bytes.Contains(want.Bytes(), []byte(`"source":"tmdb_collection"`)) { + t.Fatal("built-in catalog must contain Discover and franchise templates") + } + + rec := httptest.NewRecorder() + NewCollectionTemplateHandler(nil).HandleListTemplates(rec, httptest.NewRequest(http.MethodGet, "/admin/collections/templates", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + if !bytes.Equal(rec.Body.Bytes(), want.Bytes()) { + t.Fatalf("v1 template list changed:\n got %.300s\nwant %.300s", rec.Body.String(), want.String()) + } +} + // Bundle apply dedupes collections by slugified title per library // (applyTemplateBundle's slug-match adoption), so two builtin templates with // the same title slug can never coexist: whichever applies second is silently @@ -108,3 +132,35 @@ func TestLibraryCollectionHandlerListsTemplateBundles(t *testing.T) { t.Fatalf("unexpected bundle catalog: %+v", body) } } + +// TestV1TemplateBundleListKeepsFrozenShape pins the /api/v1 bundle list to the +// four bundle fields it has always carried. Template summaries are a /api/v2 +// addition and must not reach the frozen body. +func TestV1TemplateBundleListKeepsFrozenShape(t *testing.T) { + type v1Bundle struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + TemplateIDs []string `json:"template_ids"` + } + bundles := templates.ListBundles() + frozen := struct { + Bundles []v1Bundle `json:"bundles"` + }{Bundles: make([]v1Bundle, 0, len(bundles))} + for _, b := range bundles { + frozen.Bundles = append(frozen.Bundles, v1Bundle{ID: b.ID, Title: b.Title, Description: b.Description, TemplateIDs: b.TemplateIDs}) + } + var want bytes.Buffer + if err := json.NewEncoder(&want).Encode(frozen); err != nil { + t.Fatal(err) + } + + rec := httptest.NewRecorder() + (&LibraryCollectionHandler{}).HandleListTemplateBundles(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + if !bytes.Equal(rec.Body.Bytes(), want.Bytes()) { + t.Fatalf("v1 bundle list changed:\n got %.300s\nwant %.300s", rec.Body.String(), want.String()) + } +} diff --git a/internal/api/handlers/collections.go b/internal/api/handlers/collections.go index 6d33683a38..5f37048a03 100644 --- a/internal/api/handlers/collections.go +++ b/internal/api/handlers/collections.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net/http" + "slices" "strings" "time" @@ -27,6 +28,22 @@ type CollectionHandler struct { ArtworkStore blobstore.Store ArtworkResolver artworkurl.Resolver HTTPClient *http.Client + // CollectionOwners resolves the owner's access for another profile's + // shared collection; without it those collections cannot be read. + CollectionOwners catalog.PersonalCollectionAccess + // ItemPosters signs catalog item posters for smart previews; + // ArtworkResolver signs only stored collection artwork keys. + ItemPosters itemPosterSigner + // Collages serves and builds the collage a collection without an + // uploaded or imported poster shows; nil when artwork storage is not + // configured. + Collages *catalog.PersonalCollectionCollages +} + +// itemPosterSigner resolves item poster paths to delivery URLs in one batch; +// catalog.DetailService implements it. +type itemPosterSigner interface { + PresignImageURLs(ctx context.Context, paths []string, imageType, size string) map[string]string } // NewCollectionHandler creates a new CollectionHandler. @@ -40,19 +57,20 @@ type PersonalCollectionCreateRequest struct { Name string `json:"name"` CollectionType string `json:"collection_type"` IsShared bool `json:"is_shared"` - AllowedProfileIDs []string `json:"allowed_profile_ids"` QueryDefinition json.RawMessage `json:"query_definition"` SortConfig json.RawMessage `json:"sort_config"` DisplayQueryDefinition json.RawMessage `json:"display_query_definition"` IncludeInServerCollections bool `json:"include_in_server_collections"` PosterSourceURL string `json:"poster_source_url"` + // Description is set only by the /api/v2 adapter; the frozen /api/v1 + // create never accepted one and still ignores it. + Description string `json:"-"` } type PersonalCollectionUpdateRequest struct { Name *string `json:"name"` Description *string `json:"description"` IsShared *bool `json:"is_shared"` - AllowedProfileIDs *[]string `json:"allowed_profile_ids"` QueryDefinition json.RawMessage `json:"query_definition"` SortConfig json.RawMessage `json:"sort_config"` SourceURL *string `json:"source_url"` @@ -62,6 +80,10 @@ type PersonalCollectionUpdateRequest struct { IncludeInServerCollections *bool `json:"include_in_server_collections"` PosterSourceURL *string `json:"poster_source_url"` GroupID optionalNullableString `json:"group_id"` + // SyncSchedule is a cadence name (see usercollections.AllowedSyncSchedules) + // or "" to stop syncing. Set only by the /api/v2 adapter; the frozen + // /api/v1 update never accepted one and still ignores it. + SyncSchedule *string `json:"-"` } type collectionItemRequest struct { @@ -69,13 +91,15 @@ type collectionItemRequest struct { } type PersonalCollectionView struct { - ID string `json:"id"` - ProfileID string `json:"profile_id"` - CreatorProfileID string `json:"creator_profile_id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - CollectionType string `json:"collection_type"` - IsShared bool `json:"is_shared"` + ID string `json:"id"` + ProfileID string `json:"profile_id"` + CreatorProfileID string `json:"creator_profile_id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + CollectionType string `json:"collection_type"` + IsShared bool `json:"is_shared"` + // AllowedProfileIDs is the /api/v1 bridge's report of who sees the + // collection; only the v1 handlers fill it (v1CollectionAudience). AllowedProfileIDs []string `json:"allowed_profile_ids"` QueryDefinition json.RawMessage `json:"query_definition"` SortConfig json.RawMessage `json:"sort_config"` @@ -93,8 +117,12 @@ type PersonalCollectionView struct { IncludeInServerCollections bool `json:"include_in_server_collections"` PosterURL string `json:"poster_url,omitempty"` PosterThumbhash string `json:"poster_thumbhash,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + // PosterIsCollage reports that PosterURL is the collection's collage for + // the reading profile rather than an uploaded or imported poster. Only + // /api/v2 reads show collages and carry it. + PosterIsCollage bool `json:"-"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } type PersonalCollectionListView struct { @@ -116,6 +144,10 @@ type CollectionCapabilitiesView struct { // distinguish a server that also stores the personal-list kinds // ('watchlist', 'favorites') from one that rejects them. SortPreferenceKinds []string `json:"sort_preference_kinds"` + // PosterCollages reports that /api/v2 personal collection reads show a + // collage when a collection has no uploaded or imported poster. The + // frozen /api/v1 body does not carry it. + PosterCollages bool `json:"-"` } type CollectionDisplayFilterPresetsView struct { @@ -162,6 +194,9 @@ type PersonalCollectionItemsView struct { type PersonalCollectionPreviewRequest struct { QueryDefinition json.RawMessage `json:"query_definition"` Limit int `json:"limit"` + // WithPosters signs each item's poster. Only the /api/v2 adapter sets it; + // the frozen /api/v1 body drops posters, so it skips the presign batch. + WithPosters bool `json:"-"` } type PersonalCollectionPreviewView struct { @@ -173,13 +208,24 @@ type PersonalCollectionPreviewItemView struct { ContentID string `json:"content_id"` Title string `json:"title"` Type string `json:"type"` + // PosterURL is emitted by the /api/v2 adapter only; the frozen /api/v1 + // preview body never carried a poster. + PosterURL string `json:"-"` } // --- Handler methods --- // HandleListCollections handles GET /collections. func (h *CollectionHandler) HandleListCollections(w http.ResponseWriter, r *http.Request) { - resp, err := h.ListPersonalCollections(r.Context(), apimw.GetUserID(r.Context()), apimw.GetProfileID(r.Context())) + userID := apimw.GetUserID(r.Context()) + resp, err := h.ListPersonalCollections(r.Context(), userID, apimw.GetProfileID(r.Context())) + if err == nil { + views := make([]*PersonalCollectionView, len(resp.Collections)) + for i := range resp.Collections { + views[i] = &resp.Collections[i] + } + err = v1CollectionAudience(r.Context(), h.storeProvider, userID, views...) + } if err != nil { writeAPIError(w, err) return @@ -199,12 +245,16 @@ func (h *CollectionHandler) HandleCreateCollection(w http.ResponseWriter, r *htt writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") return } + userID := apimw.GetUserID(r.Context()) created, err := h.CreatePersonalCollection(r.Context(), PersonalCollectionCreateCommand{ - UserID: apimw.GetUserID(r.Context()), + UserID: userID, ProfileID: apimw.GetProfileID(r.Context()), Request: req, PosterFile: posterFileReader(r), }) + if err == nil { + err = v1CollectionAudience(r.Context(), h.storeProvider, userID, &created) + } if err != nil { writeAPIError(w, err) return @@ -219,7 +269,11 @@ func (h *CollectionHandler) HandleUpdateCollection(w http.ResponseWriter, r *htt writeError(w, 400, "bad_request", "Invalid request body") return } - resp, err := h.UpdatePersonalCollection(r.Context(), PersonalCollectionUpdateCommand{UserID: apimw.GetUserID(r.Context()), ProfileID: apimw.GetProfileID(r.Context()), CollectionID: chi.URLParam(r, "id"), Request: req, PosterFile: posterFileReader(r)}) + userID := apimw.GetUserID(r.Context()) + resp, err := h.UpdatePersonalCollection(r.Context(), PersonalCollectionUpdateCommand{UserID: userID, ProfileID: apimw.GetProfileID(r.Context()), CollectionID: chi.URLParam(r, "id"), Request: req, PosterFile: posterFileReader(r)}) + if err == nil { + err = v1CollectionAudience(r.Context(), h.storeProvider, userID, &resp) + } if err != nil { writeAPIError(w, err) return @@ -275,20 +329,25 @@ func (h *CollectionHandler) HandleAddCollectionItem(w http.ResponseWriter, r *ht type reorderRequest struct { OrderedIDs []string `json:"ordered_ids"` - // GroupID scopes the reorder to one section. nil/absent targets Ungrouped. + // GroupID is kept for v1 clients; personal collection groups no longer + // exist, so only null or absent is accepted. GroupID *string `json:"group_id,omitempty"` } // HandleReorderCollections handles PUT /collections/order. -// The body must contain every collection in scope; concurrent edits that -// would silently drop one are rejected. +// The body must name each of the profile's own collections exactly once; +// concurrent edits that would silently drop one are rejected. func (h *CollectionHandler) HandleReorderCollections(w http.ResponseWriter, r *http.Request) { var req reorderRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") return } - if err := h.ReorderPersonalCollections(r.Context(), apimw.GetUserID(r.Context()), apimw.GetProfileID(r.Context()), req.GroupID, req.OrderedIDs); err != nil { + if req.GroupID != nil { + writeAPIError(w, fieldError("group_id", "Personal collection groups are not supported; omit group_id")) + return + } + if err := h.ReorderPersonalCollections(r.Context(), apimw.GetUserID(r.Context()), apimw.GetProfileID(r.Context()), req.OrderedIDs); err != nil { writeAPIError(w, err) return } @@ -379,6 +438,37 @@ func (h *CollectionHandler) HandleRemoveCollectionItem(w http.ResponseWriter, r // --- Helpers --- +// v1CollectionAudience fills allowed_profile_ids for the /api/v1 bridge, which +// still reports it: the creator alone for a private collection, every profile +// on the login (sorted by ID) for a shared one. Visibility itself is decided +// from is_shared; the reported list is never read back. +func v1CollectionAudience(ctx context.Context, provider userstore.UserStoreProvider, userID int, views ...*PersonalCollectionView) error { + var login []string + for _, v := range views { + if !v.IsShared { + v.AllowedProfileIDs = []string{v.CreatorProfileID} + continue + } + if login == nil { + store, err := provider.ForUser(ctx, userID) + if err != nil { + return apiError(http.StatusInternalServerError, "internal_error", "Failed to access user store") + } + profiles, err := store.ListProfiles(ctx) + if err != nil { + return apiError(http.StatusInternalServerError, "internal_error", "Failed to list profiles") + } + login = make([]string, 0, len(profiles)) + for _, p := range profiles { + login = append(login, p.ID) + } + slices.Sort(login) + } + v.AllowedProfileIDs = slices.Clone(login) + } + return nil +} + func toCollectionResponse(c userstore.Collection) PersonalCollectionView { queryDefinition := defaultJSON([]byte(c.QueryDefinition)) sortConfig := defaultJSON([]byte(c.SortConfig)) @@ -390,7 +480,6 @@ func toCollectionResponse(c userstore.Collection) PersonalCollectionView { Description: c.Description, CollectionType: c.CollectionType, IsShared: c.IsShared, - AllowedProfileIDs: append([]string(nil), c.AllowedProfileIDs...), QueryDefinition: queryDefinition, SortConfig: sortConfig, SortOrder: c.SortOrder, diff --git a/internal/api/handlers/collections_collage_test.go b/internal/api/handlers/collections_collage_test.go new file mode 100644 index 0000000000..bb9bff1a6f --- /dev/null +++ b/internal/api/handlers/collections_collage_test.go @@ -0,0 +1,300 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "sync" + "testing" + "time" + + "github.com/Silo-Server/silo-server/internal/access" + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/usercollections" + "github.com/Silo-Server/silo-server/internal/userstore" + "github.com/Silo-Server/silo-server/internal/userstore/pgstore" +) + +// recordingCollageGenerator stores nothing; it records the collages it is +// asked to compose, and holds a compose while gate is set. +type recordingCollageGenerator struct { + mu sync.Mutex + composed map[string][]string + gate chan struct{} +} + +func (g *recordingCollageGenerator) CollectionCollagePath(collectionID, key string) string { + return fmt.Sprintf("user-collection-images/%s/collage/original.%s.webp", collectionID, key) +} + +func (g *recordingCollageGenerator) ComposeCollectionCollage(ctx context.Context, collectionID, key string, sources []string) (string, string, error) { + g.mu.Lock() + gate := g.gate + g.mu.Unlock() + if gate != nil { + select { + case <-gate: + case <-ctx.Done(): + return "", "", ctx.Err() + } + } + g.mu.Lock() + defer g.mu.Unlock() + if g.composed == nil { + g.composed = map[string][]string{} + } + g.composed[key] = slices.Clone(sources) + return g.CollectionCollagePath(collectionID, key), "th-" + key, nil +} + +func (g *recordingCollageGenerator) built(key string) ([]string, bool) { + g.mu.Lock() + defer g.mu.Unlock() + sources, ok := g.composed[key] + return sources, ok +} + +// waitForCollage waits until the generator has composed key. +func (g *recordingCollageGenerator) waitFor(t *testing.T, key string) []string { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for { + if sources, ok := g.built(key); ok { + return sources + } + if time.Now().After(deadline) { + t.Fatalf("collage %s was never composed", key) + } + time.Sleep(20 * time.Millisecond) + } +} + +// collagePosterResolver signs a stored key as itself. +type collagePosterResolver struct{} + +func (collagePosterResolver) ResolveURLs(_ context.Context, keys []string) map[string]catalog.ResolvedImageURL { + out := make(map[string]catalog.ResolvedImageURL, len(keys)) + for _, key := range keys { + out[key] = catalog.ResolvedImageURL{URL: "https://cdn.test/" + key} + } + return out +} + +// libraryOwnerAccess limits every owner to one library. +type libraryOwnerAccess struct{ library int } + +func (o libraryOwnerAccess) OwnerFilter(context.Context, int, string) (catalog.AccessFilter, error) { + return catalog.AccessFilter{AllowedLibraryIDs: []int{o.library}}, nil +} + +// TestPersonalCollectionCollagesDB covers the personal collection collage +// read and build paths through the handlers: a change builds the owner's +// collage in the background, /api/v2 lists show each profile its own collage +// (a shared collection's viewer only titles both it and the owner can see) +// without waiting for a build, and the frozen /api/v1 list shows none. +func TestPersonalCollectionCollagesDB(t *testing.T) { + f := newPagingIntegrationFixture(t) + ctx := t.Context() + provider := pgstore.NewPostgresProvider(f.pool) + store, err := provider.ForUser(ctx, f.account) + if err != nil { + t.Fatal(err) + } + for _, id := range []string{"owner", "viewer"} { + if err := store.CreateProfile(ctx, userstore.Profile{ID: id, Name: id}); err != nil { + t.Fatal(err) + } + } + // f.ids[0] lives in f.hidden, the rest in f.library. Ages: G, G, R, PG, + // PG; f.ids[1] has no poster. + poster := func(i int) string { return fmt.Sprintf("test/%s/poster/original.webp", f.ids[i]) } + for i, age := range []int{0, 0, 17, 8, 8} { + path := poster(i) + if i == 1 { + path = "" + } + f.exec(t, `UPDATE media_items SET content_rating_age=$2, poster_path=$3, title=$4, sort_title=$4, genres=ARRAY[$5::text] WHERE content_id=$1`, f.ids[i], age, path, fmt.Sprintf("t%d", i), f.ids[0]+"-genre") + } + + // Deleting the collections queues their collages for the artwork + // collector; drop those entries too. + var collectionIDs []string + t.Cleanup(func() { + ctx := context.Background() + _, _ = f.pool.Exec(ctx, `DELETE FROM user_personal_collections WHERE user_id=$1`, f.account) + for _, id := range collectionIDs { + _, _ = f.pool.Exec(ctx, `DELETE FROM artwork_revision_gc_candidates WHERE original_path LIKE $1`, "user-collection-images/"+id+"/%") + } + }) + + gen := &recordingCollageGenerator{} + h := NewCollectionHandler(provider) + h.Executor = &catalog.QueryExecutor{Pool: f.pool} + h.ArtworkResolver = collagePosterResolver{} + h.CollectionOwners = libraryOwnerAccess{library: f.library} + h.Collages = catalog.NewPersonalCollectionCollages(f.pool, gen) + h.Collages.RefreshDelay = 0 + + as := func(profile string, scope access.Scope) context.Context { + c := apimw.SetClaims(ctx, &auth.Claims{UserID: f.account, ProfileID: profile}) + return access.SetScope(c, scope) + } + ownerCtx := as("owner", access.Scope{}) + viewerCtx := as("viewer", access.Scope{MaturityLimits: access.MaturityLimits{MaxContentRating: "PG"}}) + + created, err := h.CreatePersonalCollection(ownerCtx, PersonalCollectionCreateCommand{UserID: f.account, ProfileID: "owner", Request: PersonalCollectionCreateRequest{Name: "Shared", CollectionType: "manual", IsShared: true}}) + if err != nil { + t.Fatal(err) + } + collectionIDs = append(collectionIDs, created.ID) + if created.PosterURL != "" || created.PosterIsCollage { + t.Fatalf("a new empty collection shows poster %q (collage %v)", created.PosterURL, created.PosterIsCollage) + } + for i, id := range f.ids { + if err := h.AddPersonalCollectionItem(ownerCtx, f.account, "owner", created.ID, id, i); err != nil { + t.Fatal(err) + } + } + def := []catalog.PersonalCollectionDefinition{{ID: created.ID, CollectionType: "manual"}} + ownerSources, err := h.Collages.ListSources(ctx, f.account, def, catalog.AccessFilter{}) + if err != nil { + t.Fatal(err) + } + ownerKey := catalog.CollectionCollageKey(ownerSources[created.ID]) + if want := []string{poster(0), poster(2), poster(3), poster(4)}; !slices.Equal(ownerSources[created.ID], want) { + t.Fatalf("owner sources = %v, want %v", ownerSources[created.ID], want) + } + + t.Run("adding titles builds the owner's collage", func(t *testing.T) { + gen.waitFor(t, ownerKey) + }) + + findView := func(t *testing.T, ctx context.Context, profile string) PersonalCollectionView { + t.Helper() + var list PersonalCollectionListView + deadline := time.Now().Add(10 * time.Second) + for { + // The build is composed before its row is saved; read until + // the row lands or the deadline passes. + list, err = h.ListPersonalCollections(ctx, f.account, profile) + if err != nil { + t.Fatal(err) + } + if len(list.Collections) != 1 || list.Collections[0].ID != created.ID { + t.Fatalf("list = %+v", list.Collections) + } + if list.Collections[0].PosterIsCollage || time.Now().After(deadline) { + return list.Collections[0] + } + time.Sleep(20 * time.Millisecond) + } + } + + t.Run("an /api/v2 list shows the owner its collage", func(t *testing.T) { + got := findView(t, WithNativeAPIV2(ownerCtx), "owner") + want := "https://cdn.test/" + cardThumbnailPath(gen.CollectionCollagePath(created.ID, ownerKey)) + if !got.PosterIsCollage || got.PosterURL != want || got.PosterThumbhash != "th-"+ownerKey { + t.Fatalf("owner view poster = %q (collage %v, thumbhash %q), want %q", got.PosterURL, got.PosterIsCollage, got.PosterThumbhash, want) + } + }) + + // getCollection and updateCollection answer the editor state behind a + // strong ETag, which carries no presigned poster of any kind. + t.Run("the editor state carries no poster", func(t *testing.T) { + got, err := h.PersonalCollectionEditor(WithNativeAPIV2(ownerCtx), f.account, "owner", created.ID) + if err != nil { + t.Fatal(err) + } + if got.Collection.PosterURL != "" || got.Collection.PosterIsCollage || got.Collection.PosterThumbhash != "" { + t.Fatalf("editor poster = %q (collage %v, thumbhash %q), want none", got.Collection.PosterURL, got.Collection.PosterIsCollage, got.Collection.PosterThumbhash) + } + }) + + t.Run("an /api/v2 library tab marks the owner's collage", func(t *testing.T) { + lh := &LibraryCollectionHandler{Executor: h.Executor, CollectionOwners: h.CollectionOwners, PersonalCollages: h.Collages} + got := lh.withVisibleItemCounts(WithNativeAPIV2(ownerCtx), f.account, "owner", []usercollections.ServerVisibleCollection{{ID: created.ID, CreatorProfileID: "owner", CollectionType: "manual"}}) + if len(got) != 1 || !got[0].PosterIsCollage || got[0].PosterPath != gen.CollectionCollagePath(created.ID, ownerKey) { + t.Fatalf("library tab entries = %+v, want the owner's collage %s", got, ownerKey) + } + }) + + t.Run("the /api/v1 list shows no collage", func(t *testing.T) { + list, err := h.ListPersonalCollections(ownerCtx, f.account, "owner") + if err != nil { + t.Fatal(err) + } + if got := list.Collections[0]; got.PosterURL != "" || got.PosterIsCollage { + t.Fatalf("v1 poster = %q (collage %v), want none", got.PosterURL, got.PosterIsCollage) + } + }) + + t.Run("a viewer's list answers before its collage is built, then shows its own", func(t *testing.T) { + // The owner sees only f.library and the viewer only up to PG, so + // the viewer's collage leaves out f.ids[0] and f.ids[2]. + viewerFilter := catalog.IntersectAccess(catalog.AccessFilter{MaturityLimits: access.MaturityLimits{MaxContentRating: "PG"}}, catalog.AccessFilter{AllowedLibraryIDs: []int{f.library}}) + viewerSources, err := h.Collages.ListSources(ctx, f.account, def, viewerFilter) + if err != nil { + t.Fatal(err) + } + if want := []string{poster(3), poster(4)}; !slices.Equal(viewerSources[created.ID], want) { + t.Fatalf("viewer sources = %v, want %v", viewerSources[created.ID], want) + } + viewerKey := catalog.CollectionCollageKey(viewerSources[created.ID]) + + gate := make(chan struct{}) + gen.mu.Lock() + gen.gate = gate + gen.mu.Unlock() + v2 := WithNativeAPIV2(viewerCtx) + list, err := h.ListPersonalCollections(v2, f.account, "viewer") + if err != nil { + t.Fatal(err) + } + if got := list.Collections[0]; got.PosterURL != "" || got.PosterIsCollage { + t.Fatalf("viewer's first read showed %q (collage %v) while its collage was still being built", got.PosterURL, got.PosterIsCollage) + } + gen.mu.Lock() + gen.gate = nil + gen.mu.Unlock() + close(gate) + + if built := gen.waitFor(t, viewerKey); slices.Contains(built, poster(0)) || slices.Contains(built, poster(2)) { + t.Fatalf("viewer collage built from %v", built) + } + got := findView(t, v2, "viewer") + if !got.PosterIsCollage || got.PosterThumbhash != "th-"+viewerKey { + t.Fatalf("viewer view = %+v, want collage %s", got, viewerKey) + } + }) + + t.Run("editing a smart collection's rules builds its new collage", func(t *testing.T) { + query := func(sort string) json.RawMessage { + return json.RawMessage(fmt.Sprintf(`{"match":"all","groups":[{"match":"all","rules":[{"field":"genre","op":"is","value":%q}]}],"sort":{"field":"title","order":%q}}`, f.ids[0]+"-genre", sort)) + } + smart, err := h.CreatePersonalCollection(ownerCtx, PersonalCollectionCreateCommand{UserID: f.account, ProfileID: "owner", Request: PersonalCollectionCreateRequest{Name: "Smart", CollectionType: "smart", QueryDefinition: query("asc")}}) + if err != nil { + t.Fatal(err) + } + collectionIDs = append(collectionIDs, smart.ID) + ascending := catalog.CollectionCollageKey([]string{poster(0), poster(2), poster(3), poster(4)}) + gen.waitFor(t, ascending) + + if _, err := h.UpdatePersonalCollection(ownerCtx, PersonalCollectionUpdateCommand{UserID: f.account, ProfileID: "owner", CollectionID: smart.ID, Request: PersonalCollectionUpdateRequest{QueryDefinition: query("desc")}}); err != nil { + t.Fatal(err) + } + gen.waitFor(t, catalog.CollectionCollageKey([]string{poster(4), poster(3), poster(2), poster(0)})) + }) + + t.Run("without artwork storage the account reports no artwork", func(t *testing.T) { + features, err := h.PersonalCollectionFeatures(ctx, f.account) + if err != nil { + t.Fatal(err) + } + if features.Artwork { + t.Fatal("artwork reported without artwork storage") + } + }) +} diff --git a/internal/api/handlers/collections_contains_item_test.go b/internal/api/handlers/collections_contains_item_test.go new file mode 100644 index 0000000000..ad31eae52d --- /dev/null +++ b/internal/api/handlers/collections_contains_item_test.go @@ -0,0 +1,166 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "maps" + "net/http" + "net/http/httptest" + "slices" + "testing" + "time" + + "github.com/Silo-Server/silo-server/internal/access" + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/notifications" + "github.com/Silo-Server/silo-server/internal/userstore" + "github.com/Silo-Server/silo-server/internal/userstore/pgstore" +) + +// TestPersonalCollectionsHoldingItemDB covers the Add to collection ticks: +// only the acting profile's own manual collections that hold the title, and +// nothing for a title the profile can't access, so the answer never reveals +// that such a title exists (#193 S1, S2, S3). The frozen /api/v1 list ignores +// the parameter. +func TestPersonalCollectionsHoldingItemDB(t *testing.T) { + f := newPagingIntegrationFixture(t) + ctx := t.Context() + provider := pgstore.NewPostgresProvider(f.pool) + store, err := provider.ForUser(ctx, f.account) + if err != nil { + t.Fatal(err) + } + for _, id := range []string{"owner", "viewer"} { + if err := store.CreateProfile(ctx, userstore.Profile{ID: id, Name: id}); err != nil { + t.Fatal(err) + } + } + var otherLogin int + if err := f.pool.QueryRow(ctx, `INSERT INTO users(username,role) VALUES($1,'user') RETURNING id`, fmt.Sprintf("contains-item-%d", time.Now().UnixNano())).Scan(&otherLogin); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = f.pool.Exec(context.Background(), `DELETE FROM users WHERE id=$1`, otherLogin) }) + // f.ids[0] lives in f.hidden, the rest in f.library; f.ids[2] is rated R. + f.exec(t, `UPDATE media_items SET content_rating_age=17 WHERE content_id=$1`, f.ids[2]) + + create := func(creator, name, kind string, shared bool, items ...string) string { + t.Helper() + c, err := store.CreateCollection(ctx, userstore.CreateCollectionInput{ + CreatorProfileID: creator, Name: name, CollectionType: kind, QueryDefinition: "{}", + IsShared: shared, + }) + if err != nil { + t.Fatal(err) + } + for i, item := range items { + if err := store.AddCollectionItem(ctx, c.ID, item, i); err != nil { + t.Fatal(err) + } + } + return c.ID + } + holdsAll := create("owner", "Holds all", "manual", false, f.ids[0], f.ids[1], f.ids[2]) + sharedHolds := create("owner", "Shared", "manual", true, f.ids[1]) + create("owner", "Empty", "manual", false) + create("owner", "Synced", "mdblist", false, f.ids[1]) + sharedWithOwner := create("viewer", "Viewer's", "manual", true, f.ids[1]) + + // Production wraps the store provider in the notification decorator. + h := NewCollectionHandler(notifications.WrapUserStoreProvider(provider, ¬ifications.System{})) + h.Executor = &catalog.QueryExecutor{Pool: f.pool} + + request := func(userID int, profileID string, scope *access.Scope) context.Context { + reqCtx := apimw.SetClaims(ctx, &auth.Claims{UserID: userID}) + reqCtx = apimw.SetProfileID(reqCtx, profileID) + if scope != nil { + reqCtx = access.SetScope(reqCtx, *scope) + } + return reqCtx + } + holding := func(t *testing.T, reqCtx context.Context, userID int, profileID, itemID string) []string { + t.Helper() + got, err := h.PersonalCollectionsHoldingItem(reqCtx, userID, profileID, itemID) + if err != nil { + t.Fatal(err) + } + return slices.Sorted(maps.Keys(got)) + } + sorted := func(ids ...string) []string { return slices.Sorted(slices.Values(ids)) } + + t.Run("only the profile's own manual collections that hold the title", func(t *testing.T) { + got := holding(t, request(f.account, "owner", nil), f.account, "owner", f.ids[1]) + if want := sorted(holdsAll, sharedHolds); !slices.Equal(got, want) { + t.Fatalf("holding = %v, want %v (not the synced list, not %s shared by viewer)", got, want, sharedWithOwner) + } + got = holding(t, request(f.account, "owner", nil), f.account, "owner", f.ids[3]) + if len(got) != 0 { + t.Fatalf("title in no collection: holding = %v", got) + } + }) + + t.Run("another profile's shared collection is never reported", func(t *testing.T) { + got := holding(t, request(f.account, "viewer", nil), f.account, "viewer", f.ids[1]) + if want := []string{sharedWithOwner}; !slices.Equal(got, want) { + t.Fatalf("viewer holding = %v, want only its own %v", got, want) + } + }) + + t.Run("a title the profile cannot access reports nothing", func(t *testing.T) { + libraries := &access.Scope{AllowedLibraryIDs: []int{f.library}, LibrariesRestricted: true} + if got := holding(t, request(f.account, "owner", libraries), f.account, "owner", f.ids[0]); len(got) != 0 { + t.Fatalf("title in a library the profile can't see: holding = %v", got) + } + if got := holding(t, request(f.account, "owner", libraries), f.account, "owner", f.ids[1]); !slices.Equal(got, sorted(holdsAll, sharedHolds)) { + t.Fatalf("visible title under the same restriction: holding = %v", got) + } + rating := &access.Scope{MaturityLimits: access.MaturityLimits{MaxContentRating: "PG"}} + if got := holding(t, request(f.account, "owner", rating), f.account, "owner", f.ids[2]); len(got) != 0 { + t.Fatalf("title above the rating ceiling: holding = %v", got) + } + if got := holding(t, request(f.account, "owner", nil), f.account, "owner", "no-such-title"); len(got) != 0 { + t.Fatalf("unknown title: holding = %v", got) + } + }) + + t.Run("another login reports nothing", func(t *testing.T) { + if got := holding(t, request(otherLogin, "owner", nil), otherLogin, "owner", f.ids[1]); len(got) != 0 { + t.Fatalf("other login holding = %v", got) + } + }) + + t.Run("v1 list ignores contains_item", func(t *testing.T) { + list := func(target string) []byte { + t.Helper() + req := httptest.NewRequest(http.MethodGet, target, nil) + rec := httptest.NewRecorder() + h.HandleListCollections(rec, req.WithContext(request(f.account, "owner", nil))) + if rec.Code != http.StatusOK { + t.Fatalf("v1 %s: %d %s", target, rec.Code, rec.Body.String()) + } + return rec.Body.Bytes() + } + plain := list("/api/v1/collections") + with := list("/api/v1/collections?contains_item=" + f.ids[1]) + if !bytes.Equal(plain, with) { + t.Fatalf("v1 list changed with contains_item:\n%s\n%s", plain, with) + } + var body struct { + Collections []map[string]json.RawMessage `json:"collections"` + } + if err := json.Unmarshal(with, &body); err != nil { + t.Fatal(err) + } + if len(body.Collections) == 0 { + t.Fatalf("v1 list is empty: %s", with) + } + for _, c := range body.Collections { + if _, ok := c["contains"]; ok { + t.Fatalf("v1 collection carries contains: %v", c) + } + } + }) +} diff --git a/internal/api/handlers/collections_create_description_test.go b/internal/api/handlers/collections_create_description_test.go new file mode 100644 index 0000000000..bef4426741 --- /dev/null +++ b/internal/api/handlers/collections_create_description_test.go @@ -0,0 +1,231 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "mime/multipart" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/userdb" + "github.com/Silo-Server/silo-server/internal/userstore" + "github.com/Silo-Server/silo-server/internal/userstore/pgstore" +) + +// TestPersonalCollectionCreateDescriptionDB covers the description a /api/v2 +// create carries, the frozen /api/v1 create that ignores one, and #193 S2's +// isolation of the new collection from other profiles and other logins. +func TestPersonalCollectionCreateDescriptionDB(t *testing.T) { + f := newPagingIntegrationFixture(t) + ctx := t.Context() + provider := pgstore.NewPostgresProvider(f.pool) + store, err := provider.ForUser(ctx, f.account) + if err != nil { + t.Fatal(err) + } + for _, id := range []string{"owner", "viewer"} { + if err := store.CreateProfile(ctx, userstore.Profile{ID: id, Name: id}); err != nil { + t.Fatal(err) + } + } + var otherLogin int + if err := f.pool.QueryRow(ctx, `INSERT INTO users(username,role) VALUES($1,'user') RETURNING id`, fmt.Sprintf("create-description-%d", time.Now().UnixNano())).Scan(&otherLogin); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = f.pool.Exec(context.Background(), `DELETE FROM users WHERE id=$1`, otherLogin) }) + h := NewCollectionHandler(provider) + + stored := func(t *testing.T, id string) string { + t.Helper() + c, err := store.GetCollection(ctx, id) + if err != nil { + t.Fatal(err) + } + return c.Description + } + + t.Run("create stores the description", func(t *testing.T) { + created, err := h.CreatePersonalCollection(ctx, PersonalCollectionCreateCommand{UserID: f.account, ProfileID: "owner", Request: PersonalCollectionCreateRequest{ + Name: "Rainy days", Description: "For wet afternoons", + }}) + if err != nil { + t.Fatal(err) + } + if created.Description != "For wet afternoons" { + t.Fatalf("created description = %q", created.Description) + } + if got := stored(t, created.ID); got != "For wet afternoons" { + t.Fatalf("stored description = %q", got) + } + read, err := h.GetPersonalCollection(ctx, f.account, "owner", created.ID) + if err != nil { + t.Fatal(err) + } + if read.Description != "For wet afternoons" { + t.Fatalf("read description = %q", read.Description) + } + }) + + t.Run("create without a description stores none", func(t *testing.T) { + created, err := h.CreatePersonalCollection(ctx, PersonalCollectionCreateCommand{UserID: f.account, ProfileID: "owner", Request: PersonalCollectionCreateRequest{Name: "No description"}}) + if err != nil { + t.Fatal(err) + } + if created.Description != "" || stored(t, created.ID) != "" { + t.Fatalf("description = %q, stored %q", created.Description, stored(t, created.ID)) + } + }) + + // /api/v1 is frozen: its create never took a description, and a client + // that sends one still gets a collection without it. + v1Create := func(t *testing.T, req *http.Request) PersonalCollectionView { + t.Helper() + reqCtx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: f.account}) + reqCtx = apimw.SetProfileID(reqCtx, "owner") + rec := httptest.NewRecorder() + h.HandleCreateCollection(rec, req.WithContext(reqCtx)) + if rec.Code != http.StatusCreated { + t.Fatalf("v1 create: %d %s", rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), `"description"`) { + t.Fatalf("v1 create echoed a description: %s", rec.Body.String()) + } + var created PersonalCollectionView + if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil { + t.Fatal(err) + } + return created + } + t.Run("v1 JSON create ignores a description", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/v1/collections", strings.NewReader(`{"name":"v1 JSON","description":"ignored"}`)) + req.Header.Set("Content-Type", "application/json") + created := v1Create(t, req) + if created.Name != "v1 JSON" || stored(t, created.ID) != "" { + t.Fatalf("name %q, stored description %q", created.Name, stored(t, created.ID)) + } + }) + t.Run("v1 multipart create ignores a description", func(t *testing.T) { + var body bytes.Buffer + form := multipart.NewWriter(&body) + if err := form.WriteField("data", `{"name":"v1 multipart","description":"ignored"}`); err != nil { + t.Fatal(err) + } + if err := form.Close(); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/collections", &body) + req.Header.Set("Content-Type", form.FormDataContentType()) + created := v1Create(t, req) + if created.Name != "v1 multipart" || stored(t, created.ID) != "" { + t.Fatalf("name %q, stored description %q", created.Name, stored(t, created.ID)) + } + }) + + // #193 S2: a private collection made with a description is invisible to + // another profile on the account and to another login; a shared one can be + // read but not changed by a profile that doesn't own it. + requireStatus := func(t *testing.T, err error, status int) { + t.Helper() + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.Status != status { + t.Fatalf("error = %v, want status %d", err, status) + } + } + rename := "Taken over" + t.Run("another profile cannot read or change a private collection", func(t *testing.T) { + private, err := h.CreatePersonalCollection(ctx, PersonalCollectionCreateCommand{UserID: f.account, ProfileID: "owner", Request: PersonalCollectionCreateRequest{Name: "Private", Description: "Mine"}}) + if err != nil { + t.Fatal(err) + } + _, err = h.GetPersonalCollection(ctx, f.account, "viewer", private.ID) + requireStatus(t, err, http.StatusNotFound) + _, err = h.UpdatePersonalCollection(ctx, PersonalCollectionUpdateCommand{UserID: f.account, ProfileID: "viewer", CollectionID: private.ID, Request: PersonalCollectionUpdateRequest{Description: &rename}}) + requireStatus(t, err, http.StatusNotFound) + list, err := h.ListPersonalCollections(ctx, f.account, "viewer") + if err != nil { + t.Fatal(err) + } + for _, c := range list.Collections { + if c.ID == private.ID { + t.Fatalf("viewer lists the owner's private collection %s", c.ID) + } + } + _, err = h.GetPersonalCollection(ctx, otherLogin, "owner", private.ID) + requireStatus(t, err, http.StatusNotFound) + if got := stored(t, private.ID); got != "Mine" { + t.Fatalf("description after refused writes = %q", got) + } + }) + t.Run("another profile cannot change a shared collection", func(t *testing.T) { + shared, err := h.CreatePersonalCollection(ctx, PersonalCollectionCreateCommand{UserID: f.account, ProfileID: "owner", Request: PersonalCollectionCreateRequest{ + Name: "Shared", Description: "Ours", IsShared: true, + }}) + if err != nil { + t.Fatal(err) + } + read, err := h.GetPersonalCollection(ctx, f.account, "viewer", shared.ID) + if err != nil { + t.Fatal(err) + } + if read.Description != "Ours" { + t.Fatalf("viewer reads description %q", read.Description) + } + _, err = h.UpdatePersonalCollection(ctx, PersonalCollectionUpdateCommand{UserID: f.account, ProfileID: "viewer", CollectionID: shared.ID, Request: PersonalCollectionUpdateRequest{Description: &rename}}) + requireStatus(t, err, http.StatusForbidden) + _, err = h.GetPersonalCollection(ctx, otherLogin, "viewer", shared.ID) + requireStatus(t, err, http.StatusNotFound) + if got := stored(t, shared.ID); got != "Ours" { + t.Fatalf("description after refused write = %q", got) + } + }) +} + +// TestPersonalCollectionCreateDescriptionUnsupportedStore covers a store with +// no description column: a create carrying one is refused rather than stored +// without it, and a create without one still succeeds. +func TestPersonalCollectionCreateDescriptionUnsupportedStore(t *testing.T) { + const account = 1 + db, err := userdb.NewUserDB(filepath.Join(t.TempDir(), "user.db"), account) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + store := userdb.NewSQLiteUserStore(db.DB) + if err := store.CreateProfile(t.Context(), userstore.Profile{ID: "owner", Name: "owner"}); err != nil { + t.Fatal(err) + } + h := NewCollectionHandler(pagingIntegrationProvider{account: account, store: store}) + + _, err = h.CreatePersonalCollection(t.Context(), PersonalCollectionCreateCommand{UserID: account, ProfileID: "owner", Request: PersonalCollectionCreateRequest{ + Name: "Rainy days", Description: "For wet afternoons", + }}) + if apiErr := (*APIError)(nil); !errors.As(err, &apiErr) || apiErr.Status != http.StatusNotImplemented { + t.Fatalf("error = %v, want status %d", err, http.StatusNotImplemented) + } + list, err := store.ListCollections(t.Context(), "owner") + if err != nil { + t.Fatal(err) + } + if len(list) != 0 { + t.Fatalf("refused create stored %d collections", len(list)) + } + if _, err := h.CreatePersonalCollection(t.Context(), PersonalCollectionCreateCommand{UserID: account, ProfileID: "owner", Request: PersonalCollectionCreateRequest{Name: "Plain"}}); err != nil { + t.Fatal(err) + } + features, err := h.PersonalCollectionFeatures(t.Context(), account) + if err != nil { + t.Fatal(err) + } + if features.Description { + t.Fatal("SQLite store reports description support") + } +} diff --git a/internal/api/handlers/collections_editor_service.go b/internal/api/handlers/collections_editor_service.go index 6f220d63a6..c23e626196 100644 --- a/internal/api/handlers/collections_editor_service.go +++ b/internal/api/handlers/collections_editor_service.go @@ -29,7 +29,6 @@ type PersonalCollectionEditorView struct { Revision int64 } type PersonalCollectionOrderView struct { - GroupID *string OrderedIDs []string Revision int64 HasMore bool @@ -65,15 +64,23 @@ func (h *CollectionHandler) PersonalCollectionEditor(ctx context.Context, userID return out, collectionPageError(userstore.ErrCollectionChanged) } // This is the canonical editor representation. Presigned URLs are displayed - // from the collection listing and never participate in a strong validator. - view.PosterURL = "" + // from the collection listing and never participate in a strong validator, + // so the editor carries no poster of any kind. A collage's thumbhash goes + // too: the collage is built in the background and per viewer, so it + // changes without a revision. + if view.PosterIsCollage { + view.PosterThumbhash = "" + } + view.PosterURL, view.PosterIsCollage = "", false out.Collection = view out.Revision = rev return out, nil } -func (h *CollectionHandler) PersonalCollectionOrderEditor(ctx context.Context, userID int, profileID string, groupID *string) (PersonalCollectionOrderView, error) { - out := PersonalCollectionOrderView{GroupID: groupID, OrderedIDs: []string{}} +// PersonalCollectionOrderEditor reads the profile's own collections in its +// order. +func (h *CollectionHandler) PersonalCollectionOrderEditor(ctx context.Context, userID int, profileID string) (PersonalCollectionOrderView, error) { + out := PersonalCollectionOrderView{OrderedIDs: []string{}} store, err := h.storeProvider.ForUser(ctx, userID) if err != nil { return out, apiError(500, "internal_error", "Failed to access user store") @@ -88,16 +95,15 @@ func (h *CollectionHandler) PersonalCollectionOrderEditor(ctx context.Context, u if err != nil { return out, err } - view, err := h.ListPersonalCollections(ctx, userID, profileID) + // The order covers the profile's own collections, read from the store: + // it needs no member counts, so it never depends on resolving another + // owner's access. The store lists them first, in the profile's order. + collections, err := store.ListCollections(ctx, profileID) if err != nil { - return out, err + return out, apiError(http.StatusInternalServerError, "internal_error", "Failed to list collections") } - for _, c := range view.Collections { - same := c.GroupID == nil && groupID == nil - if c.GroupID != nil && groupID != nil { - same = *c.GroupID == *groupID - } - if same { + for _, c := range collections { + if c.CreatorProfileID == profileID { out.OrderedIDs = append(out.OrderedIDs, c.ID) } } @@ -248,15 +254,15 @@ func reorderCollectionItemsWithRevision(ctx context.Context, s userstore.UserSto } return s.ReorderCollectionItems(ctx, id, ids) } -func reorderCollectionsWithRevision(ctx context.Context, s userstore.UserStore, profile string, group *string, ids []string) error { +func reorderCollectionsWithRevision(ctx context.Context, s userstore.UserStore, profile string, ids []string) error { if expected := collectionExpectedRevision(ctx); expected != nil { cas, e := mutationStore(s) if e != nil { return e } - return cas.ReorderCollectionsIfRevision(ctx, profile, group, ids, *expected) + return cas.ReorderCollectionsIfRevision(ctx, profile, ids, *expected) } - return s.ReorderCollections(ctx, profile, group, ids) + return s.ReorderCollections(ctx, profile, ids) } func updateCollectionGroupWithRevision(ctx context.Context, s userstore.UserStore, id string, name, slug *string, mode *userstore.GroupSortMode) (*userstore.CollectionGroup, error) { if expected := collectionExpectedRevision(ctx); expected != nil { diff --git a/internal/api/handlers/collections_item_counts.go b/internal/api/handlers/collections_item_counts.go index 0b57533f56..3947816da8 100644 --- a/internal/api/handlers/collections_item_counts.go +++ b/internal/api/handlers/collections_item_counts.go @@ -3,6 +3,7 @@ package handlers import ( "context" "log/slog" + "maps" "strings" "github.com/Silo-Server/silo-server/internal/catalog" @@ -53,3 +54,67 @@ func visiblePersonalCollectionCounts(ctx context.Context, executor *catalog.Quer } return counts } + +// ownedCollectionDefinition is a personal collection's count definition and +// the profile that owns it. +type ownedCollectionDefinition struct { + catalog.PersonalCollectionDefinition + CreatorProfileID string + // WantsCollage marks a collection with no uploaded or imported poster, + // which shows a collage of its titles instead. + WantsCollage bool +} + +// ownedCollectionReads is what ownerScopedCollectionReads answers. +type ownedCollectionReads struct { + counts map[string]int + // posters holds the collage each WantsCollage collection shows the + // viewer, when one is stored. + posters map[string]catalog.CollectionPoster + // unavailable lists the collections whose owner could not be resolved. + unavailable map[string]bool +} + +// ownerScopedCollectionReads counts each collection as viewerProfileID sees +// it, and with collages set finds the collage it shows that viewer: under the +// viewer's filter, limited further to its owner's access when another profile +// owns it (catalog.PersonalCollectionFilter). Collections are read per owner, +// so each owner is resolved once per call. A collection whose owner cannot be +// resolved has no count or collage and is listed in unavailable; callers drop +// it rather than read it under the viewer's access alone. +func ownerScopedCollectionReads(ctx context.Context, executor *catalog.QueryExecutor, owners catalog.PersonalCollectionAccess, collages *catalog.PersonalCollectionCollages, userID int, viewerProfileID string, collections []ownedCollectionDefinition, viewer catalog.AccessFilter) ownedCollectionReads { + out := ownedCollectionReads{counts: make(map[string]int, len(collections)), posters: map[string]catalog.CollectionPoster{}, unavailable: make(map[string]bool)} + if executor == nil || executor.Pool == nil { + return out + } + byOwner := make(map[string][]ownedCollectionDefinition) + var order []string + for _, c := range collections { + if _, ok := byOwner[c.CreatorProfileID]; !ok { + order = append(order, c.CreatorProfileID) + } + byOwner[c.CreatorProfileID] = append(byOwner[c.CreatorProfileID], c) + } + for _, owner := range order { + owned := byOwner[owner] + filter, err := catalog.PersonalCollectionFilter(ctx, owners, viewer, userID, viewerProfileID, owner) + if err != nil { + slog.WarnContext(ctx, "resolving personal collection owner access failed", "component", "collections", "owner_profile_id", owner, "error", err) + for _, c := range owned { + out.unavailable[c.ID] = true + } + continue + } + definitions := make([]catalog.PersonalCollectionDefinition, 0, len(owned)) + var collaged []catalog.PersonalCollectionDefinition + for _, c := range owned { + definitions = append(definitions, c.PersonalCollectionDefinition) + if c.WantsCollage { + collaged = append(collaged, c.PersonalCollectionDefinition) + } + } + maps.Copy(out.counts, visiblePersonalCollectionCounts(ctx, executor, userID, definitions, filter)) + maps.Copy(out.posters, collages.Posters(ctx, userID, collaged, filter)) + } + return out +} diff --git a/internal/api/handlers/collections_lifecycle_service.go b/internal/api/handlers/collections_lifecycle_service.go index 1c32269f28..03cc62c073 100644 --- a/internal/api/handlers/collections_lifecycle_service.go +++ b/internal/api/handlers/collections_lifecycle_service.go @@ -6,10 +6,12 @@ import ( "encoding/json" "errors" "net/http" + "slices" "strings" "github.com/Silo-Server/silo-server/internal/catalog" "github.com/Silo-Server/silo-server/internal/collectionutil" + "github.com/Silo-Server/silo-server/internal/models" "github.com/Silo-Server/silo-server/internal/usercollections" "github.com/Silo-Server/silo-server/internal/userstore" ) @@ -44,14 +46,19 @@ func (h *CollectionHandler) UpdatePersonalCollection(ctx context.Context, cmd Pe return none, err } } - if req.SourceURL != nil || req.MaxItems != nil || req.LibraryIDs != nil { + if req.SourceURL != nil || req.MaxItems != nil || req.LibraryIDs != nil || req.SyncSchedule != nil { if err := collectionFeatureError(store, "imports"); err != nil { return none, err } } if req.GroupID.Set() { if err := collectionFeatureError(store, "groups"); err != nil { - return none, err + if group := req.GroupID.Value(); group != nil && strings.TrimSpace(*group) != "" { + return none, err + } + // A null or blank group_id asks for no group, which every + // collection already has on a store without groups. + req.GroupID = optionalNullableString{} } } input := userstore.UpdateCollectionInput{ @@ -60,7 +67,6 @@ func (h *CollectionHandler) UpdatePersonalCollection(ctx context.Context, cmd Pe RequestProfileID: profileID, Name: req.Name, Description: req.Description, - AllowedProfileIDs: req.AllowedProfileIDs, IncludeInServerCollections: req.IncludeInServerCollections, } if req.DisplayQueryDefinition != nil { @@ -142,6 +148,21 @@ func (h *CollectionHandler) UpdatePersonalCollection(ctx context.Context, cmd Pe input.SourceConfigPatch = new(string(raw)) } + if req.SyncSchedule != nil { + if !catalog.IsSyncableType(existing.CollectionType) { + return none, fieldError("sync_schedule", "sync_schedule can only be set on a synced list") + } + schedule, err := usercollections.ResolveSyncSchedule(*req.SyncSchedule) + if err != nil { + return none, fieldError("sync_schedule", err.Error()) + } + if schedule == nil { + input.ClearSyncSchedule, input.ClearNextSyncAt = true, true + } else { + input.SyncSchedule, input.NextSyncAt = schedule, usercollections.InitialNextSyncAt(schedule) + } + } + if err := store.UpdateCollection(ctx, input); err != nil { if errors.Is(err, userstore.ErrCollectionRevisionMismatch) { return none, err @@ -168,8 +189,9 @@ func (h *CollectionHandler) UpdatePersonalCollection(ctx context.Context, cmd Pe } return none, apiError(http.StatusInternalServerError, "internal_error", "Failed to retrieve updated collection") } + h.refreshCollage(ctx, store, userID, collection) - return h.collectionView(ctx, store, userID, *collection), nil + return h.collectionView(ctx, store, userID, profileID, *collection) } func (h *CollectionHandler) PreviewPersonalCollection(ctx context.Context, req PersonalCollectionPreviewRequest, filter catalog.AccessFilter) (PersonalCollectionPreviewView, error) { @@ -194,17 +216,40 @@ func (h *CollectionHandler) PreviewPersonalCollection(ctx context.Context, req P return none, apiError(http.StatusBadRequest, "bad_request", err.Error()) } + var posters map[string]string + if req.WithPosters { + posters = h.previewPosterURLs(ctx, items) + } resp := PersonalCollectionPreviewView{Items: make([]PersonalCollectionPreviewItemView, 0, len(items)), Total: total} for _, item := range items { resp.Items = append(resp.Items, PersonalCollectionPreviewItemView{ ContentID: item.ContentID, Title: item.Title, Type: item.Type, + PosterURL: posters[item.PosterPath], }) } return resp, nil } +// previewPosterURLs signs the card-size posters of the previewed items in one +// batch, keyed by poster path. Items without a poster are not looked up. +func (h *CollectionHandler) previewPosterURLs(ctx context.Context, items []*models.MediaItem) map[string]string { + if h.ItemPosters == nil { + return nil + } + paths := make([]string, 0, len(items)) + for _, item := range items { + if item.PosterPath != "" { + paths = append(paths, item.PosterPath) + } + } + if len(paths) == 0 { + return nil + } + return h.ItemPosters.PresignImageURLs(ctx, paths, "poster", "small") +} + func (h *CollectionHandler) DeletePersonalCollection(ctx context.Context, userID int, profileID, collectionID string) error { if collectionID == "" { @@ -227,7 +272,9 @@ func (h *CollectionHandler) DeletePersonalCollection(ctx context.Context, userID } // ListPersonalCollectionItems preserves the unpaged bridge response. V2 callers -// must use the bounded continuation service instead. +// must use the bounded continuation service instead. It lists the stored +// members the reader can access: for another profile's collection, only those +// its owner can access too, as on every other read. func (h *CollectionHandler) ListPersonalCollectionItems(ctx context.Context, userID int, profileID, collectionID string) (PersonalCollectionItemsView, error) { var none PersonalCollectionItemsView @@ -235,7 +282,7 @@ func (h *CollectionHandler) ListPersonalCollectionItems(ctx context.Context, use return none, apiError(http.StatusBadRequest, "bad_request", "Collection ID is required") } - store, _, err := h.personalCollectionStore(ctx, userID, profileID, collectionID, false) + store, collection, err := h.personalCollectionStore(ctx, userID, profileID, collectionID, false) if err != nil { return none, err } @@ -244,6 +291,10 @@ func (h *CollectionHandler) ListPersonalCollectionItems(ctx context.Context, use if err != nil { return none, apiError(http.StatusInternalServerError, "internal_error", "Failed to list collection items") } + items, err = h.accessibleCollectionItems(ctx, userID, profileID, collection.CreatorProfileID, items) + if err != nil { + return none, err + } resp := PersonalCollectionItemsView{ Items: make([]PersonalCollectionItemView, 0, len(items)), @@ -260,6 +311,38 @@ func (h *CollectionHandler) ListPersonalCollectionItems(ctx context.Context, use return resp, nil } +// accessibleCollectionItems keeps the stored members of ownerProfileID's +// collection that profileID may see (catalog.PersonalCollectionFilter), in +// their stored order. Hidden members stay stored. +func (h *CollectionHandler) accessibleCollectionItems(ctx context.Context, userID int, profileID, ownerProfileID string, items []userstore.CollectionItem) ([]userstore.CollectionItem, error) { + if len(items) == 0 { + return items, nil + } + filter, err := catalog.PersonalCollectionFilter(ctx, h.CollectionOwners, AccessFilterFromContext(ctx, ""), userID, profileID, ownerProfileID) + if err != nil { + return nil, apiError(http.StatusInternalServerError, "internal_error", "Failed to list collection items") + } + reader, err := h.itemReader() + if err != nil { + return nil, err + } + ids := make([]string, 0, len(items)) + for _, item := range items { + ids = append(ids, item.MediaItemID) + } + visible, err := reader.GetByIDsWithAccess(ctx, ids, filter) + if err != nil { + return nil, apiError(http.StatusInternalServerError, "internal_error", "Failed to list collection items") + } + allowed := make(map[string]bool, len(visible)) + for _, item := range visible { + if item != nil { + allowed[item.ContentID] = true + } + } + return slices.DeleteFunc(items, func(item userstore.CollectionItem) bool { return !allowed[item.MediaItemID] }), nil +} + func (h *CollectionHandler) AddPersonalCollectionItem(ctx context.Context, userID int, profileID, collectionID, itemID string, position int) error { if collectionID == "" || itemID == "" { @@ -281,6 +364,7 @@ func (h *CollectionHandler) AddPersonalCollectionItem(ctx context.Context, userI if err := store.AddCollectionItem(ctx, collectionID, itemID, position); err != nil { return apiError(http.StatusInternalServerError, "internal_error", "Failed to add item to collection") } + h.refreshCollage(ctx, store, userID, collection) return nil } @@ -306,6 +390,7 @@ func (h *CollectionHandler) ReorderPersonalCollectionItems(ctx context.Context, } return apiError(http.StatusBadRequest, "bad_request", err.Error()) } + h.refreshCollage(ctx, store, userID, collection) return nil } @@ -328,6 +413,7 @@ func (h *CollectionHandler) RemovePersonalCollectionItem(ctx context.Context, us if err := store.RemoveCollectionItem(ctx, collectionID, itemID); err != nil { return apiError(http.StatusInternalServerError, "internal_error", "Failed to remove item from collection") } + h.refreshCollage(ctx, store, userID, collection) return nil } @@ -340,7 +426,7 @@ func (h *CollectionHandler) DeletePersonalCollectionImage(ctx context.Context, u return apiError(http.StatusBadRequest, "bad_request", `type must be "poster"`) } - store, _, err := h.personalCollectionStore(ctx, userID, profileID, collectionID, true) + store, collection, err := h.personalCollectionStore(ctx, userID, profileID, collectionID, true) if err != nil { return err } @@ -363,9 +449,21 @@ func (h *CollectionHandler) DeletePersonalCollectionImage(ctx context.Context, u } return apiError(http.StatusInternalServerError, "internal_error", "Failed to clear poster") } + // The collection shows its collage again. + collection.PosterURL = "" + h.refreshCollage(ctx, store, userID, collection) return nil } +// refreshCollage builds, in the background, the collage the acting profile +// sees for c when c has no uploaded or imported poster, so it is ready before +// the next read. Only the creator changes a collection, so the acting +// profile's filter is the owner's. Stores kept outside Postgres have no +// collage. +func (h *CollectionHandler) refreshCollage(ctx context.Context, store userstore.UserStore, userID int, c *userstore.Collection) { + usercollections.RefreshCollage(h.Collages, store, userID, c, AccessFilterFromContext(ctx, "")) +} + // personalCollectionStore checks the account and profile before exposing or changing a collection. func (h *CollectionHandler) personalCollectionStore(ctx context.Context, userID int, profileID, id string, mutate bool) (userstore.UserStore, *userstore.Collection, error) { store, err := h.storeProvider.ForUser(ctx, userID) @@ -394,7 +492,7 @@ func (h *CollectionHandler) GetPersonalCollection(ctx context.Context, userID in if err != nil { return PersonalCollectionView{}, err } - return h.collectionView(ctx, store, userID, *c), nil + return h.collectionView(ctx, store, userID, profileID, *c) } // UploadPersonalCollectionPoster stores uploaded image bytes for a collection's diff --git a/internal/api/handlers/collections_lifecycle_service_test.go b/internal/api/handlers/collections_lifecycle_service_test.go index 3179c9568a..13f2ea059d 100644 --- a/internal/api/handlers/collections_lifecycle_service_test.go +++ b/internal/api/handlers/collections_lifecycle_service_test.go @@ -4,9 +4,12 @@ import ( "context" "encoding/json" "errors" + "fmt" + "net/http" "testing" "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/usercollections" "github.com/Silo-Server/silo-server/internal/userstore" ) @@ -51,10 +54,25 @@ func (p lifecycleProvider) ForUser(context.Context, int) (userstore.UserStore, e return p.store, nil } +// Every profile on the login reads a shared collection; only its owner +// changes it (403 for the others). A private collection does not exist for +// anyone but its owner (404). func TestPersonalCollectionLifecycleProfileAccess(t *testing.T) { - for _, profile := range []string{"owner", "viewer", "hidden", ""} { - t.Run(profile, func(t *testing.T) { - store := &lifecycleStore{collection: userstore.Collection{ID: "c", CreatorProfileID: "owner", AllowedProfileIDs: []string{"owner", "viewer"}, CollectionType: "manual"}} + for _, tc := range []struct { + profile string + shared bool + wantRead bool + want int + }{ + {profile: "owner", shared: false, wantRead: true}, + {profile: "owner", shared: true, wantRead: true}, + {profile: "viewer", shared: true, wantRead: true, want: 403}, + {profile: "viewer", shared: false, want: 404}, + {profile: "", shared: true, want: 404}, + } { + t.Run(fmt.Sprintf("%s/shared=%t", tc.profile, tc.shared), func(t *testing.T) { + profile := tc.profile + store := &lifecycleStore{collection: userstore.Collection{ID: "c", CreatorProfileID: "owner", IsShared: tc.shared, CollectionType: "manual"}} h := NewCollectionHandler(lifecycleProvider{store: store}) h.ItemReader = &collectionGuardReader{items: []*models.MediaItem{{ContentID: "item"}}} for name, operation := range map[string]func() error{ @@ -66,45 +84,37 @@ func TestPersonalCollectionLifecycleProfileAccess(t *testing.T) { _, err := h.UpdatePersonalCollection(t.Context(), PersonalCollectionUpdateCommand{UserID: 1, ProfileID: profile, CollectionID: "c"}) return err }, + "artwork": func() error { return h.DeletePersonalCollectionImage(t.Context(), 1, profile, "c", "poster") }, } { err := operation() - if profile == "owner" { - if err != nil { + if tc.want == 0 { + if err != nil && name != "artwork" { t.Fatalf("%s: %v", name, err) } continue } - want := 404 - if profile == "viewer" { - want = 403 - } e, ok := errors.AsType[*APIError](err) - if !ok || e.Status != want { - t.Errorf("%s error = %v; want %d", name, err, want) + if !ok || e.Status != tc.want { + t.Errorf("%s error = %v; want %d", name, err, tc.want) } } - if profile != "owner" && store.mutations != 0 { + if tc.want != 0 && store.mutations != 0 { t.Fatalf("unauthorized mutations: %d", store.mutations) } _, err := h.ListPersonalCollectionItems(t.Context(), 1, profile, "c") - if (err == nil) != (profile == "owner" || profile == "viewer") { + if (err == nil) != tc.wantRead { t.Errorf("list error = %v", err) } _, err = h.GetPersonalCollection(t.Context(), 1, profile, "c") - if (err == nil) != (profile == "owner" || profile == "viewer") { + if (err == nil) != tc.wantRead { t.Errorf("get error = %v", err) } - if profile != "owner" { - if err := h.DeletePersonalCollectionImage(t.Context(), 1, profile, "c", "poster"); err == nil { - t.Fatal("unauthorized artwork deletion accepted") - } - } }) } } func TestPersonalCollectionUpdatePreservesNullableGroupAndImportConfig(t *testing.T) { - store := &lifecycleStore{collection: userstore.Collection{ID: "c", CreatorProfileID: "owner", AllowedProfileIDs: []string{"owner"}, CollectionType: "mdblist", SourceConfig: `{"url":"https://mdblist.com/lists/user/list","limit":10,"library_ids":[7]}`}} + store := &lifecycleStore{collection: userstore.Collection{ID: "c", CreatorProfileID: "owner", CollectionType: "mdblist", SourceConfig: `{"url":"https://mdblist.com/lists/user/list","limit":10,"library_ids":[7]}`}} h := NewCollectionHandler(lifecycleProvider{store: store}) var req PersonalCollectionUpdateRequest if err := json.Unmarshal([]byte(`{"group_id":null,"max_items":0}`), &req); err != nil { @@ -125,9 +135,54 @@ func TestPersonalCollectionUpdatePreservesNullableGroupAndImportConfig(t *testin } } +// groupLessStore reports the features of a store without collection groups, +// as both user stores do since groups were retired. +type groupLessStore struct{ *lifecycleStore } + +func (groupLessStore) CollectionFeatures() userstore.CollectionFeatures { + return userstore.CollectionFeatures{Imports: true, Artwork: true, ItemReorder: true, Description: true} +} + +type groupLessProvider struct { + userstore.UserStoreProvider + store groupLessStore +} + +func (p groupLessProvider) ForUser(context.Context, int) (userstore.UserStore, error) { + return p.store, nil +} + +func TestPersonalCollectionUpdateWithoutGroupsIgnoresNullGroup(t *testing.T) { + for _, body := range []string{`{"name":"Renamed","group_id":null}`, `{"name":"Renamed","group_id":" "}`} { + inner := &lifecycleStore{collection: userstore.Collection{ID: "c", CreatorProfileID: "owner", CollectionType: "manual"}} + h := NewCollectionHandler(groupLessProvider{store: groupLessStore{inner}}) + var req PersonalCollectionUpdateRequest + if err := json.Unmarshal([]byte(body), &req); err != nil { + t.Fatal(err) + } + if _, err := h.UpdatePersonalCollection(t.Context(), PersonalCollectionUpdateCommand{UserID: 1, ProfileID: "owner", CollectionID: "c", Request: req}); err != nil { + t.Fatalf("%s: %v", body, err) + } + if inner.update.Name == nil || *inner.update.Name != "Renamed" || inner.update.GroupID != nil { + t.Fatalf("%s: update = %#v, want the rename without a group change", body, inner.update) + } + } + + inner := &lifecycleStore{collection: userstore.Collection{ID: "c", CreatorProfileID: "owner", CollectionType: "manual"}} + h := NewCollectionHandler(groupLessProvider{store: groupLessStore{inner}}) + var req PersonalCollectionUpdateRequest + if err := json.Unmarshal([]byte(`{"name":"Renamed","group_id":"g1"}`), &req); err != nil { + t.Fatal(err) + } + _, err := h.UpdatePersonalCollection(t.Context(), PersonalCollectionUpdateCommand{UserID: 1, ProfileID: "owner", CollectionID: "c", Request: req}) + if apiErr, ok := errors.AsType[*APIError](err); !ok || apiErr.Status != http.StatusNotImplemented { + t.Fatalf("named group error = %#v, want 501", err) + } +} + func TestPersonalLegacyTraktSourceCannotBeEdited(t *testing.T) { store := &lifecycleStore{collection: userstore.Collection{ - ID: "c", CreatorProfileID: "owner", AllowedProfileIDs: []string{"owner"}, + ID: "c", CreatorProfileID: "owner", CollectionType: "trakt", SourceConfig: `{"url":"https://trakt.tv/users/example/lists/list"}`, }} h := NewCollectionHandler(lifecycleProvider{store: store}) @@ -144,3 +199,20 @@ func TestPersonalLegacyTraktSourceCannotBeEdited(t *testing.T) { t.Fatalf("source edit caused %d mutations", store.mutations) } } + +// Sync answers like every other mutation: a collection the profile cannot +// see does not exist for it (404), and a shared collection it does not own +// is refused (403). +func TestSyncPersonalCollectionNotFoundVersusForbidden(t *testing.T) { + for _, tc := range []struct { + shared bool + want int + }{{shared: false, want: 404}, {shared: true, want: 403}} { + store := &lifecycleStore{collection: userstore.Collection{ID: "c", CreatorProfileID: "owner", IsShared: tc.shared, CollectionType: "mdblist"}} + h := NewUserCollectionImportHandler(lifecycleProvider{store: store}, &usercollections.Service{}, nil, nil, nil, nil) + _, err := h.SyncPersonalCollection(t.Context(), 1, "viewer", "c") + if e, ok := errors.AsType[*APIError](err); !ok || e.Status != tc.want { + t.Errorf("shared=%t: sync error = %v; want %d", tc.shared, err, tc.want) + } + } +} diff --git a/internal/api/handlers/collections_mutation_guards.go b/internal/api/handlers/collections_mutation_guards.go index 72ea01c070..0ae40cbb27 100644 --- a/internal/api/handlers/collections_mutation_guards.go +++ b/internal/api/handlers/collections_mutation_guards.go @@ -16,12 +16,9 @@ type collectionMutationItemReader interface { // the selected viewer's library or rating scope. Both native transports use // the scope populated by their authentication/profile middleware. func (h *CollectionHandler) requireVisibleCollectionItem(ctx context.Context, itemID string) error { - reader := h.ItemReader - if reader == nil && h.Executor != nil && h.Executor.Pool != nil { - reader = catalog.NewItemRepository(h.Executor.Pool) - } - if reader == nil { - return apiError(http.StatusServiceUnavailable, "unavailable", "Catalog access is unavailable") + reader, err := h.itemReader() + if err != nil { + return err } items, err := reader.GetByIDsWithAccess(ctx, []string{itemID}, AccessFilterFromContext(ctx, "")) if err != nil { @@ -34,3 +31,15 @@ func (h *CollectionHandler) requireVisibleCollectionItem(ctx context.Context, it } return apiError(http.StatusNotFound, "not_found", "Item not found") } + +// itemReader reads catalog items under an access filter: the injected reader, +// else the catalog behind the executor. +func (h *CollectionHandler) itemReader() (collectionMutationItemReader, error) { + if h.ItemReader != nil { + return h.ItemReader, nil + } + if h.Executor != nil && h.Executor.Pool != nil { + return catalog.NewItemRepository(h.Executor.Pool), nil + } + return nil, apiError(http.StatusServiceUnavailable, "unavailable", "Catalog access is unavailable") +} diff --git a/internal/api/handlers/collections_mutation_guards_test.go b/internal/api/handlers/collections_mutation_guards_test.go index 81ef54a04d..13d895f295 100644 --- a/internal/api/handlers/collections_mutation_guards_test.go +++ b/internal/api/handlers/collections_mutation_guards_test.go @@ -27,7 +27,7 @@ func TestPersonalCollectionManualMutationGuards(t *testing.T) { for _, kind := range []string{"manual", "smart", "mdblist", "trakt", ""} { for _, visible := range []bool{true, false} { t.Run(kind+"/"+map[bool]string{true: "visible", false: "missing-or-hidden"}[visible], func(t *testing.T) { - store := &lifecycleStore{collection: userstore.Collection{ID: "c", CreatorProfileID: "owner", AllowedProfileIDs: []string{"owner"}, CollectionType: kind}} + store := &lifecycleStore{collection: userstore.Collection{ID: "c", CreatorProfileID: "owner", CollectionType: kind}} reader := &collectionGuardReader{} if visible { reader.items = []*models.MediaItem{{ContentID: "item"}} diff --git a/internal/api/handlers/collections_owner_access_test.go b/internal/api/handlers/collections_owner_access_test.go new file mode 100644 index 0000000000..97dc13bbaf --- /dev/null +++ b/internal/api/handlers/collections_owner_access_test.go @@ -0,0 +1,631 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "slices" + "testing" + + "github.com/Silo-Server/silo-server/internal/access" + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/policy" + "github.com/Silo-Server/silo-server/internal/sections" + "github.com/Silo-Server/silo-server/internal/settingscontract" + "github.com/Silo-Server/silo-server/internal/settingskeys" + "github.com/Silo-Server/silo-server/internal/usercollections" + "github.com/Silo-Server/silo-server/internal/userstore" + "github.com/Silo-Server/silo-server/internal/userstore/pgstore" +) + +// failingCollectionOwners stands in for an owner whose access cannot be +// resolved, such as a policy evaluation failure. +type failingCollectionOwners struct{} + +func (failingCollectionOwners) OwnerFilter(context.Context, int, string) (catalog.AccessFilter, error) { + return catalog.AccessFilter{}, errors.New("owner scope unavailable") +} + +// TestSharedPersonalCollectionOwnerAccessDB pins #1615's membership rule: a +// personal collection shared with another profile shows that profile only the +// titles both the owner and the viewer can access, on every read surface, and +// follows the owner's access as it changes. +func TestSharedPersonalCollectionOwnerAccessDB(t *testing.T) { + f := newPagingIntegrationFixture(t) + ctx := t.Context() + // The account repository behind the production resolver reads these. + f.exec(t, `UPDATE users SET email=username||'@example.test', password_hash='' WHERE id=$1`, f.account) + provider := pgstore.NewPostgresProvider(f.pool) + store, err := provider.ForUser(ctx, f.account) + if err != nil { + t.Fatal(err) + } + for _, id := range []string{"owner", "viewer"} { + if err := store.CreateProfile(ctx, userstore.Profile{ID: id, Name: id}); err != nil { + t.Fatal(err) + } + } + // f.ids[0] lives in f.hidden, the rest in f.library. Ages: G, G, R, + // PG, unrated. A PG ceiling admits f.ids[0], f.ids[1] and f.ids[3]. + // Each title also carries its own genre, so the catalog filter facets + // show which titles a collection's facets were built from. + genre := func(i int) string { return fmt.Sprintf("owner-access-genre-%d", i) } + for i, age := range []any{0, 0, 17, 8, nil} { + f.exec(t, `UPDATE media_items SET content_rating_age=$2, genres=ARRAY[$3::text] WHERE content_id=$1`, f.ids[i], age, genre(i)) + } + + shared := func(name, kind, query, display string) *userstore.Collection { + t.Helper() + c, err := store.CreateCollection(ctx, userstore.CreateCollectionInput{ + CreatorProfileID: "owner", Name: name, CollectionType: kind, QueryDefinition: query, DisplayQueryDefinition: display, + IsShared: true, IncludeInServerCollections: true, + }) + if err != nil { + t.Fatal(err) + } + if kind == "manual" { + for i, id := range f.ids { + if err := store.AddCollectionItem(ctx, c.ID, id, i); err != nil { + t.Fatal(err) + } + } + } + return c + } + manual := shared("Manual", "manual", "", "") + // A display filter moves the count onto the dynamic count path. + displayed := shared("Displayed", "manual", "", `{"match":"all","groups":[{"match":"all","rules":[{"field":"type","op":"is","value":"movie"}]}]}`) + smart := shared("Smart", "smart", fmt.Sprintf(`{"library_ids":[%d,%d],"media_scope":"movie","match":"all","groups":[],"sort":{"field":"title","order":"asc"}}`, f.library, f.hidden), "") + // A default sort moves the home row onto the sorted query path. + sorted := shared("Sorted", "manual", "", "") + sortConfig := `{"field":"title","order":"asc"}` + if err := store.UpdateCollection(ctx, userstore.UpdateCollectionInput{ID: sorted.ID, RequestProfileID: "owner", SortConfig: &sortConfig}); err != nil { + t.Fatal(err) + } + collections := []*userstore.Collection{manual, displayed, smart, sorted} + + engine, err := policy.NewEngine(ctx) + if err != nil { + t.Fatal(err) + } + resolver := policy.NewViewerResolver(auth.NewUserRepository(f.pool), provider, nil, policy.NewPDP(engine), access.NewGroupStore(f.pool)) + owners := usercollections.NewOwnerAccess(resolver) + + h := NewCollectionHandler(provider) + h.Executor = &catalog.QueryExecutor{Pool: f.pool} + libraryHandler := NewLibraryCollectionHandler(catalog.NewLibraryCollectionRepository(f.pool), nil, catalog.NewItemRepository(f.pool), nil) + libraryHandler.FolderRepo = catalog.NewFolderRepository(f.pool) + libraryHandler.Executor = h.Executor + libraryHandler.UserCollectionPool = f.pool + catalogResolver := catalog.NewCatalogResolver(catalog.NewBrowseRepository(f.pool), catalog.NewItemRepository(f.pool)).WithUserStoreProvider(provider) + fetcher := sections.NewFetcher(f.pool) + fetcher.StoreProvider = provider + wire := func(o catalog.PersonalCollectionAccess) { + h.CollectionOwners = o + libraryHandler.CollectionOwners = o + catalogResolver.WithPersonalCollectionAccess(o) + fetcher.CollectionOwners = o + } + wire(owners) + sectionHandler := NewSectionHandler(sections.NewRepository(f.pool), fetcher) + sectionHandler.StoreProvider = provider + // Each profile's home page carries a row of every collection. + for _, profile := range []string{"owner", "viewer"} { + rows := make([]userstore.SectionOverride, 0, len(collections)) + for i, c := range collections { + position, limit := 1000+i, 50 + rows = append(rows, userstore.SectionOverride{ + ID: "row-" + c.ID, Scope: "home", Position: &position, ItemLimit: &limit, IsUserAdded: true, + UserSectionType: string(sections.SectionCollection), UserTitle: c.Name, + UserConfig: fmt.Sprintf(`{"user_collection_id":%q}`, c.ID), + }) + } + if err := store.SaveSectionOverrides(ctx, profile, "home", "", rows); err != nil { + t.Fatal(err) + } + } + + setProfile := func(t *testing.T, id, rating string, libraries []int) { + t.Helper() + restricted := libraries != nil + if err := store.UpdateProfile(ctx, id, userstore.UpdateProfileInput{MaxContentRating: &rating, LibraryRestrictionsEnabled: &restricted, AllowedLibraryIDs: &libraries}); err != nil { + t.Fatal(err) + } + } + reset := func(t *testing.T) { + t.Helper() + setProfile(t, "owner", "", nil) + setProfile(t, "viewer", "", nil) + wire(owners) + } + // readerContext is what the viewer access gate stores for a request. + readerContext := func(t *testing.T, profileID string) (context.Context, catalog.AccessFilter) { + t.Helper() + scope, err := resolver.Resolve(ctx, access.ResolveInput{UserID: f.account, ProfileID: profileID, SkipPINVerification: true}) + if err != nil { + t.Fatal(err) + } + reqCtx := apimw.SetClaims(ctx, &auth.Claims{UserID: f.account}) + reqCtx = apimw.SetProfileID(reqCtx, profileID) + reqCtx = access.SetScope(reqCtx, scope) + filter := AccessFilterFromContext(reqCtx, "") + filter.UserID, filter.ProfileID = f.account, profileID + return reqCtx, filter + } + ids := func(items []string) []string { + slices.Sort(items) + return items + } + want := func(indexes ...int) []string { + out := make([]string, 0, len(indexes)) + for _, i := range indexes { + out = append(out, f.ids[i]) + } + return ids(out) + } + + // assertMembers checks every read surface shows profileID exactly the + // fixture titles at indexes in each collection. + assertMembers := func(t *testing.T, profileID string, indexes ...int) { + t.Helper() + reqCtx, filter := readerContext(t, profileID) + expected := want(indexes...) + expectedGenres := make([]string, 0, len(indexes)) + for _, i := range indexes { + expectedGenres = append(expectedGenres, genre(i)) + } + slices.Sort(expectedGenres) + counts := map[string]int{} + list, err := h.ListPersonalCollections(reqCtx, f.account, profileID) + if err != nil { + t.Fatal(err) + } + for _, c := range list.Collections { + counts[c.ID] = c.ItemCount + } + tab, err := libraryHandler.LibraryUserCollections(reqCtx, f.library, f.account, profileID) + if err != nil { + t.Fatal(err) + } + tabCounts := map[string]int{} + for _, c := range tab { + tabCounts[c.ID] = c.ItemCount + } + for _, c := range collections { + if got := counts[c.ID]; got != len(expected) { + t.Errorf("%s: list item_count = %d, want %d", c.Name, got, len(expected)) + } + if got := tabCounts[c.ID]; got != len(expected) { + t.Errorf("%s: library tab item_count = %d, want %d", c.Name, got, len(expected)) + } + detail, err := h.GetPersonalCollection(reqCtx, f.account, profileID, c.ID) + if err != nil { + t.Fatal(err) + } + if detail.ItemCount != len(expected) { + t.Errorf("%s: detail item_count = %d, want %d", c.Name, detail.ItemCount, len(expected)) + } + + var paged []string + page, err := h.PersonalCollectionItemsPage(reqCtx, f.account, profileID, c.ID, filter, userstore.CollectionItemsPageOptions{Limit: 50}, nil) + if err != nil { + t.Fatalf("%s: items page: %v", c.Name, err) + } + for _, item := range page.Items { + paged = append(paged, item.MediaItemID) + } + if got := ids(paged); !slices.Equal(got, expected) { + t.Errorf("%s: items page = %v, want %v", c.Name, got, expected) + } + + for _, cursor := range []bool{true, false} { + result, err := catalogResolver.Resolve(reqCtx, catalog.CatalogRequest{Source: catalog.CatalogSourceUserCollection, CollectionID: c.ID, CursorPaging: cursor, UseSourceOrder: true, Limit: 50}, filter) + if err != nil { + t.Fatalf("%s: catalog (cursor %t): %v", c.Name, cursor, err) + } + var got []string + for _, item := range result.Items { + got = append(got, item.ContentID) + } + if got = ids(got); !slices.Equal(got, expected) { + t.Errorf("%s: catalog (cursor %t) = %v, want %v", c.Name, cursor, got, expected) + } + } + + // The v1 bridge lists stored members (smart collections store + // none), limited the same way. + if c.CollectionType == "manual" { + v1, err := h.ListPersonalCollectionItems(reqCtx, f.account, profileID, c.ID) + if err != nil { + t.Fatalf("%s: v1 items: %v", c.Name, err) + } + var listed []string + for _, item := range v1.Items { + listed = append(listed, item.MediaItemID) + } + if got := ids(listed); !slices.Equal(got, expected) { + t.Errorf("%s: v1 items = %v, want %v", c.Name, got, expected) + } + } + + // The catalog filter facets are built from the same members. + source := catalog.CatalogRequest{Source: catalog.CatalogSourceUserCollection, CollectionID: c.ID} + facets, err := catalogResolver.ListFilters(reqCtx, source, filter) + if err != nil { + t.Fatalf("%s: catalog filters: %v", c.Name, err) + } + if got := ids(facets.Genres); !slices.Equal(got, expectedGenres) { + t.Errorf("%s: catalog filter genres = %v, want %v", c.Name, got, expectedGenres) + } + matches, err := catalogResolver.SearchFacet(reqCtx, source, filter, "genre", "owner-access-genre", 50) + if err != nil { + t.Fatalf("%s: catalog facet search: %v", c.Name, err) + } + if got := ids(matches.Matches); !slices.Equal(got, expectedGenres) { + t.Errorf("%s: catalog facet search = %v, want %v", c.Name, got, expectedGenres) + } + + // The home row goes through the production path, which also + // scopes the row to the viewer's own allowed libraries. + row, err := sectionHandler.HomeSectionItems(reqCtx, "row-"+c.ID, SectionViewer{Access: filter}) + if err != nil { + t.Fatalf("%s: home row: %v", c.Name, err) + } + var rowIDs []string + for _, item := range row.Items { + rowIDs = append(rowIDs, item.ContentID) + } + if got := ids(rowIDs); !slices.Equal(got, expected) { + t.Errorf("%s: home row = %v, want %v", c.Name, got, expected) + } + } + } + + t.Run("both unrestricted see every member", func(t *testing.T) { + reset(t) + assertMembers(t, "viewer", 0, 1, 2, 3, 4) + }) + t.Run("a PG owner limits an unrestricted viewer", func(t *testing.T) { + reset(t) + setProfile(t, "owner", "PG", nil) + assertMembers(t, "viewer", 0, 1, 3) + // The owner's limit does not hide those titles from the viewer + // anywhere else: the viewer's own reads are unchanged. + _, viewerFilter := readerContext(t, "viewer") + visible, err := catalog.NewItemRepository(f.pool).GetByIDsWithAccess(ctx, f.ids, viewerFilter) + if err != nil { + t.Fatal(err) + } + if len(visible) != len(f.ids) { + t.Fatalf("viewer's own access sees %d titles, want %d", len(visible), len(f.ids)) + } + }) + t.Run("an unrestricted owner keeps a PG viewer's limit", func(t *testing.T) { + reset(t) + setProfile(t, "viewer", "PG", nil) + assertMembers(t, "viewer", 0, 1, 3) + }) + t.Run("an owner who loses a library hides its titles until access returns", func(t *testing.T) { + reset(t) + setProfile(t, "owner", "", []int{f.library}) + assertMembers(t, "viewer", 1, 2, 3, 4) + setProfile(t, "owner", "", nil) + assertMembers(t, "viewer", 0, 1, 2, 3, 4) + }) + t.Run("a restricted viewer sees only the narrower owner's libraries", func(t *testing.T) { + reset(t) + setProfile(t, "viewer", "", []int{f.library, f.hidden}) + setProfile(t, "owner", "", []int{f.library}) + assertMembers(t, "viewer", 1, 2, 3, 4) + setProfile(t, "owner", "", []int{f.hidden}) + assertMembers(t, "viewer", 0) + // A viewer allowed no library sees no home row member, even of an + // unrestricted owner's collection. (The library tab and other + // surfaces answer not found for such a viewer, so only the row is + // checked.) + setProfile(t, "owner", "", nil) + setProfile(t, "viewer", "", []int{}) + reqCtx, filter := readerContext(t, "viewer") + for _, c := range collections { + row, err := sectionHandler.HomeSectionItems(reqCtx, "row-"+c.ID, SectionViewer{Access: filter}) + if err != nil { + t.Fatalf("%s: home row: %v", c.Name, err) + } + if len(row.Items) != 0 { + t.Errorf("%s: home row shows %d items to a viewer allowed no library, want none", c.Name, len(row.Items)) + } + } + }) + t.Run("both limits apply together", func(t *testing.T) { + reset(t) + setProfile(t, "owner", "", []int{f.library}) + setProfile(t, "viewer", "PG", nil) + assertMembers(t, "viewer", 1, 3) + }) + t.Run("disjoint library limits hide a title both reach through different libraries", func(t *testing.T) { + // Known limit of IntersectAccess: allowed libraries intersect as + // lists, not per title. f.ids[1] is in both libraries and each + // profile can open it, but no library is allowed to both, so the + // shared collection hides it. It fails closed: nothing outside + // either profile's access is shown. + reset(t) + f.exec(t, `INSERT INTO media_item_libraries(content_id,media_folder_id) VALUES($1,$2)`, f.ids[1], f.hidden) + t.Cleanup(func() { + _, _ = f.pool.Exec(context.Background(), `DELETE FROM media_item_libraries WHERE content_id=$1 AND media_folder_id=$2`, f.ids[1], f.hidden) + }) + setProfile(t, "owner", "", []int{f.hidden}) + setProfile(t, "viewer", "", []int{f.library}) + for _, profile := range []string{"owner", "viewer"} { + _, own := readerContext(t, profile) + visible, err := catalog.NewItemRepository(f.pool).GetByIDsWithAccess(ctx, f.ids[1:2], own) + if err != nil { + t.Fatal(err) + } + if len(visible) != 1 { + t.Fatalf("%s cannot open the title in both libraries on its own", profile) + } + } + assertMembers(t, "viewer") + }) + t.Run("the owner's hidden libraries do not limit the viewer", func(t *testing.T) { + reset(t) + // A restricted owner is the case that matters: hiding a library + // removes it from the owner's own allowed list. + setProfile(t, "owner", "", []int{f.library, f.hidden}) + if _, err := store.UpsertSettingValue(ctx, userstore.SettingIdentity{ + Key: settingskeys.UiDisabledLibraryIds, Scope: settingscontract.ScopeProfile, ProfileID: "owner", + }, json.RawMessage(fmt.Sprintf(`[%d]`, f.hidden))); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if _, err := store.UpsertSettingValue(context.Background(), userstore.SettingIdentity{ + Key: settingskeys.UiDisabledLibraryIds, Scope: settingscontract.ScopeProfile, ProfileID: "owner", + }, json.RawMessage(`[]`)); err != nil { + t.Error(err) + } + }) + assertMembers(t, "viewer", 0, 1, 2, 3, 4) + }) + t.Run("the owner reads its own collections with its own access and no owner lookup", func(t *testing.T) { + reset(t) + setProfile(t, "owner", "", []int{f.library}) + wire(failingCollectionOwners{}) + assertMembers(t, "owner", 1, 2, 3, 4) + }) + t.Run("an unresolvable owner hides the collection rather than widening it", func(t *testing.T) { + reset(t) + setProfile(t, "owner", "PG", nil) + wire(failingCollectionOwners{}) + reqCtx, filter := readerContext(t, "viewer") + list, err := h.ListPersonalCollections(reqCtx, f.account, "viewer") + if err != nil { + t.Fatal(err) + } + if len(list.Collections) != 0 { + t.Errorf("list shows %d collections whose owner is unavailable, want none", len(list.Collections)) + } + tab, err := libraryHandler.LibraryUserCollections(reqCtx, f.library, f.account, "viewer") + if err != nil { + t.Fatal(err) + } + if len(tab) != 0 { + t.Errorf("library tab shows %d collections whose owner is unavailable, want none", len(tab)) + } + // Ordering never reads members, so it never depends on resolving an + // owner. A profile orders only its own collections: the viewer has + // none, and the owner's order still offers all of its own. + order, err := h.PersonalCollectionOrderEditor(reqCtx, f.account, "viewer") + if err != nil { + t.Fatal(err) + } + if len(order.OrderedIDs) != 0 { + t.Errorf("viewer's order editor = %v, want none of the owner's collections", order.OrderedIDs) + } + order, err = h.PersonalCollectionOrderEditor(reqCtx, f.account, "owner") + if err != nil { + t.Fatal(err) + } + wantOrder := []string{manual.ID, displayed.ID, smart.ID, sorted.ID} + if got := ids(slices.Clone(order.OrderedIDs)); !slices.Equal(got, ids(wantOrder)) { + t.Errorf("owner's order editor = %v, want %v", order.OrderedIDs, wantOrder) + } + for _, c := range collections { + if _, err := h.GetPersonalCollection(reqCtx, f.account, "viewer", c.ID); err == nil { + t.Errorf("%s: detail answered without its owner's access", c.Name) + } + _, err := h.PersonalCollectionItemsPage(reqCtx, f.account, "viewer", c.ID, filter, userstore.CollectionItemsPageOptions{Limit: 50}, nil) + assertPagingAPIStatus(t, err, 500) + if c.CollectionType == "manual" { + _, err = h.ListPersonalCollectionItems(reqCtx, f.account, "viewer", c.ID) + assertPagingAPIStatus(t, err, 500) + } + for _, cursor := range []bool{true, false} { + if _, err := catalogResolver.Resolve(reqCtx, catalog.CatalogRequest{Source: catalog.CatalogSourceUserCollection, CollectionID: c.ID, CursorPaging: cursor, UseSourceOrder: true, Limit: 50}, filter); !errors.Is(err, catalog.ErrPersonalCollectionOwnerAccess) { + t.Errorf("%s: catalog (cursor %t) err = %v, want ErrPersonalCollectionOwnerAccess", c.Name, cursor, err) + } + } + source := catalog.CatalogRequest{Source: catalog.CatalogSourceUserCollection, CollectionID: c.ID} + if _, err := catalogResolver.ListFilters(reqCtx, source, filter); !errors.Is(err, catalog.ErrPersonalCollectionOwnerAccess) { + t.Errorf("%s: catalog filters err = %v, want ErrPersonalCollectionOwnerAccess", c.Name, err) + } + if _, err := fetcher.FetchOne(reqCtx, sections.ResolvedSection{ + ID: "row-" + c.ID, SectionType: sections.SectionCollection, Title: c.Name, ItemLimit: 50, + Config: json.RawMessage(fmt.Sprintf(`{"user_collection_id":%q}`, c.ID)), + }, nil, nil, f.account, "viewer", filter); !errors.Is(err, catalog.ErrPersonalCollectionOwnerAccess) { + t.Errorf("%s: home row err = %v, want ErrPersonalCollectionOwnerAccess", c.Name, err) + } + } + }) + t.Run("a smart collection outside the allowed libraries shows nothing", func(t *testing.T) { + // The query's own libraries share none with the reader's + // (owner-narrowed) allowed list. Dropping the empty intersection + // would run the query across every library. + reset(t) + hiddenQuery := fmt.Sprintf(`{"library_ids":[%d],"media_scope":"movie","match":"all","groups":[],"sort":{"field":"title","order":"asc"}}`, f.hidden) + outside := []*userstore.Collection{ + shared("Hidden smart", "smart", hiddenQuery, ""), + shared("Hidden smart displayed", "smart", hiddenQuery, `{"match":"all","groups":[{"match":"all","rules":[{"field":"type","op":"is","value":"movie"}]}]}`), + } + for _, profile := range []string{"owner", "viewer"} { + rows := make([]userstore.SectionOverride, 0, len(outside)) + for i, c := range outside { + position, limit := 2000+i, 50 + rows = append(rows, userstore.SectionOverride{ + ID: "row-" + c.ID, Scope: "home", Position: &position, ItemLimit: &limit, IsUserAdded: true, + UserSectionType: string(sections.SectionCollection), UserTitle: c.Name, + UserConfig: fmt.Sprintf(`{"user_collection_id":%q}`, c.ID), + }) + } + if err := store.SaveSectionOverrides(ctx, profile, "home", "", rows); err != nil { + t.Fatal(err) + } + } + assertNone := func(t *testing.T, profileID string) { + t.Helper() + reqCtx, filter := readerContext(t, profileID) + for _, c := range outside { + detail, err := h.GetPersonalCollection(reqCtx, f.account, profileID, c.ID) + if err != nil { + t.Fatal(err) + } + if detail.ItemCount != 0 { + t.Errorf("%s: detail item_count = %d, want 0", c.Name, detail.ItemCount) + } + page, err := h.PersonalCollectionItemsPage(reqCtx, f.account, profileID, c.ID, filter, userstore.CollectionItemsPageOptions{Limit: 50}, nil) + if err != nil { + t.Fatalf("%s: items page: %v", c.Name, err) + } + if len(page.Items) != 0 { + t.Errorf("%s: items page shows %d items, want none", c.Name, len(page.Items)) + } + for _, cursor := range []bool{true, false} { + result, err := catalogResolver.Resolve(reqCtx, catalog.CatalogRequest{Source: catalog.CatalogSourceUserCollection, CollectionID: c.ID, CursorPaging: cursor, UseSourceOrder: true, Limit: 50}, filter) + if err != nil { + t.Fatalf("%s: catalog (cursor %t): %v", c.Name, cursor, err) + } + if len(result.Items) != 0 { + t.Errorf("%s: catalog (cursor %t) shows %d items, want none", c.Name, cursor, len(result.Items)) + } + } + row, err := sectionHandler.HomeSectionItems(reqCtx, "row-"+c.ID, SectionViewer{Access: filter}) + if err != nil { + t.Fatalf("%s: home row: %v", c.Name, err) + } + if len(row.Items) != 0 { + t.Errorf("%s: home row shows %d items, want none", c.Name, len(row.Items)) + } + // A library page scopes the row to its own library, here + // one outside the allowed list. + libraryRow, err := fetcher.FetchOne(reqCtx, sections.ResolvedSection{ + ID: "row-" + c.ID, SectionType: sections.SectionCollection, Title: c.Name, ItemLimit: 50, + Config: json.RawMessage(fmt.Sprintf(`{"user_collection_id":%q}`, c.ID)), + }, &f.hidden, nil, f.account, profileID, filter) + if err != nil { + t.Fatalf("%s: library row: %v", c.Name, err) + } + if len(libraryRow.Items) != 0 { + t.Errorf("%s: library row shows %d items, want none", c.Name, len(libraryRow.Items)) + } + } + } + setProfile(t, "owner", "", []int{f.library}) + // An unrestricted viewer's home row carries no library scope. + assertNone(t, "viewer") + // A restricted viewer's home row carries its own allowed libraries. + setProfile(t, "viewer", "", []int{f.library, f.hidden}) + assertNone(t, "viewer") + // The owner's own reads stay within its own allowed libraries. + assertNone(t, "owner") + }) +} + +type countingCollectionOwners struct{ calls []string } + +func (c *countingCollectionOwners) OwnerFilter(_ context.Context, _ int, owner string) (catalog.AccessFilter, error) { + c.calls = append(c.calls, owner) + return catalog.AccessFilter{}, nil +} + +// TestPersonalCollectionOwnerLookupBudgetDB pins what the owner limit costs: +// a listing resolves each other owner once however many of its collections +// it shows, and a profile reading its own collections resolves nobody. +func TestPersonalCollectionOwnerLookupBudgetDB(t *testing.T) { + f := newPagingIntegrationFixture(t) + ctx := t.Context() + provider := pgstore.NewPostgresProvider(f.pool) + store, err := provider.ForUser(ctx, f.account) + if err != nil { + t.Fatal(err) + } + create := func(creator, kind, query, display string, shared bool) *userstore.Collection { + t.Helper() + input := userstore.CreateCollectionInput{CreatorProfileID: creator, Name: kind, CollectionType: kind, QueryDefinition: query, DisplayQueryDefinition: display, IncludeInServerCollections: true} + if shared { + input.IsShared = true + } + c, err := store.CreateCollection(ctx, input) + if err != nil { + t.Fatal(err) + } + if err := store.AddCollectionItem(ctx, c.ID, f.ids[1], 0); err != nil { + t.Fatal(err) + } + return c + } + smartDef := fmt.Sprintf(`{"library_ids":[%d],"media_scope":"movie","match":"all","groups":[],"sort":{"field":"title","order":"asc"}}`, f.library) + ownerManual := create("owner", "manual", "", "", true) + create("owner", "smart", smartDef, "", true) + create("owner", "manual", "", `{"match":"all","groups":[{"match":"all","rules":[{"field":"type","op":"is","value":"movie"}]}]}`, true) + create("viewer", "manual", "", "", false) + + owners := &countingCollectionOwners{} + h := NewCollectionHandler(provider) + h.Executor = &catalog.QueryExecutor{Pool: f.pool} + h.CollectionOwners = owners + libraryHandler := NewLibraryCollectionHandler(catalog.NewLibraryCollectionRepository(f.pool), nil, catalog.NewItemRepository(f.pool), nil) + libraryHandler.FolderRepo = catalog.NewFolderRepository(f.pool) + libraryHandler.Executor = h.Executor + libraryHandler.UserCollectionPool = f.pool + libraryHandler.CollectionOwners = owners + + expect := func(t *testing.T, what string, want []string) { + t.Helper() + if !slices.Equal(owners.calls, want) { + t.Fatalf("%s resolved owners %v, want %v", what, owners.calls, want) + } + owners.calls = nil + } + list, err := h.ListPersonalCollections(ctx, f.account, "viewer") + if err != nil { + t.Fatal(err) + } + if len(list.Collections) != 4 { + t.Fatalf("viewer lists %d collections, want 4", len(list.Collections)) + } + expect(t, "the viewer's listing", []string{"owner"}) + tab, err := libraryHandler.LibraryUserCollections(ctx, f.library, f.account, "viewer") + if err != nil { + t.Fatal(err) + } + if len(tab) != 4 { + t.Fatalf("viewer's library tab lists %d collections, want 4", len(tab)) + } + expect(t, "the viewer's library tab", []string{"owner"}) + + if _, err := h.ListPersonalCollections(ctx, f.account, "owner"); err != nil { + t.Fatal(err) + } + if _, err := h.GetPersonalCollection(ctx, f.account, "owner", ownerManual.ID); err != nil { + t.Fatal(err) + } + if _, err := h.PersonalCollectionItemsPage(ctx, f.account, "owner", ownerManual.ID, catalog.AccessFilter{}, userstore.CollectionItemsPageOptions{Limit: 10}, nil); err != nil { + t.Fatal(err) + } + if _, err := libraryHandler.LibraryUserCollections(ctx, f.library, f.account, "owner"); err != nil { + t.Fatal(err) + } + expect(t, "the owner's own reads", nil) +} diff --git a/internal/api/handlers/collections_paging_service.go b/internal/api/handlers/collections_paging_service.go index e51a52e3e1..2d5e414ae7 100644 --- a/internal/api/handlers/collections_paging_service.go +++ b/internal/api/handlers/collections_paging_service.go @@ -54,6 +54,11 @@ func (h *CollectionHandler) PersonalCollectionItemsPage(ctx context.Context, use if err != nil { return out, err } + // Another profile's collection shows only what its owner can access too. + access, err = catalog.PersonalCollectionFilter(ctx, h.CollectionOwners, access, userID, profileID, c.CreatorProfileID) + if err != nil { + return out, collectionPageError(err) + } if catalog.IsLiveQueryType(c.CollectionType) { if opts.Revision != 0 && opts.Revision != witness { return out, collectionPageError(userstore.ErrCollectionChanged) diff --git a/internal/api/handlers/collections_preview_posters_test.go b/internal/api/handlers/collections_preview_posters_test.go new file mode 100644 index 0000000000..48263209e4 --- /dev/null +++ b/internal/api/handlers/collections_preview_posters_test.go @@ -0,0 +1,104 @@ +package handlers + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "slices" + "strings" + "testing" + + "github.com/Silo-Server/silo-server/internal/access" + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/userstore/pgstore" +) + +// fakeItemPosters signs each item poster path it is given and records the +// batches, so a test can see which posters a preview asked for. +type fakeItemPosters struct{ batches [][]string } + +func (f *fakeItemPosters) PresignImageURLs(_ context.Context, paths []string, imageType, size string) map[string]string { + f.batches = append(f.batches, slices.Clone(paths)) + out := make(map[string]string, len(paths)) + for _, p := range paths { + out[p] = "https://img.example/" + imageType + "/" + size + "/" + strings.ReplaceAll(p, "://", "/") + } + return out +} + +// TestPersonalCollectionPreviewPostersDB covers the poster each smart preview +// item carries for /api/v2, the frozen /api/v1 preview body that never had +// one (and so signs none), and #193 S3/S4: a PG profile's preview holds no title above its +// ceiling and none from a library it cannot see, and signs no poster for them. +func TestPersonalCollectionPreviewPostersDB(t *testing.T) { + f := newPagingIntegrationFixture(t) + // f.ids[0] lives in f.hidden, the rest in f.library. Ages: G, G, R, PG, + // unrated. A PG ceiling over f.library admits only f.ids[1] and f.ids[3]. + posters := []string{"tmdb://hidden.jpg", "tmdb://one.jpg", "tmdb://restricted.jpg", "", "tmdb://unrated.jpg"} + for i, age := range []any{0, 0, 17, 8, nil} { + f.exec(t, `UPDATE media_items SET title=$2, content_rating_age=$3, poster_path=$4 WHERE content_id=$1`, f.ids[i], fmt.Sprintf("Preview %d", i), age, posters[i]) + } + h := NewCollectionHandler(pgstore.NewPostgresProvider(f.pool)) + h.Executor = &catalog.QueryExecutor{Pool: f.pool} + signer := &fakeItemPosters{} + h.ItemPosters = signer + + query := fmt.Sprintf(`{"library_ids":[%d,%d],"media_scope":"movie","match":"all","groups":[],"sort":{"field":"title","order":"asc"}}`, f.library, f.hidden) + scope := access.Scope{AllowedLibraryIDs: []int{f.library}, MaturityLimits: access.MaturityLimits{MaxContentRating: "PG"}} + filter := catalog.AccessFilter{AllowedLibraryIDs: scope.AllowedLibraryIDs, MaturityLimits: scope.MaturityLimits} + visible := []string{f.ids[1], f.ids[3]} + + t.Run("items carry a signed poster only when they have one", func(t *testing.T) { + signer.batches = nil + got, err := h.PreviewPersonalCollection(t.Context(), PersonalCollectionPreviewRequest{QueryDefinition: []byte(query), Limit: 20, WithPosters: true}, filter) + if err != nil { + t.Fatal(err) + } + if got.Total != 2 || len(got.Items) != 2 { + t.Fatalf("preview = %+v, want only %v", got, visible) + } + want := []PersonalCollectionPreviewItemView{ + {ContentID: f.ids[1], Title: "Preview 1", Type: "movie", PosterURL: "https://img.example/poster/small/tmdb/one.jpg"}, + {ContentID: f.ids[3], Title: "Preview 3", Type: "movie"}, + } + if !slices.Equal(got.Items, want) { + t.Fatalf("items = %+v, want %+v", got.Items, want) + } + // One batch, holding only the visible title's poster: nothing above + // the ceiling or from the hidden library is signed. + if len(signer.batches) != 1 || !slices.Equal(signer.batches[0], []string{"tmdb://one.jpg"}) { + t.Fatalf("signed batches = %v", signer.batches) + } + }) + + t.Run("without a poster signer items carry no poster", func(t *testing.T) { + unsigned := NewCollectionHandler(pgstore.NewPostgresProvider(f.pool)) + unsigned.Executor = h.Executor + got, err := unsigned.PreviewPersonalCollection(t.Context(), PersonalCollectionPreviewRequest{QueryDefinition: []byte(query), Limit: 20, WithPosters: true}, filter) + if err != nil { + t.Fatal(err) + } + for _, item := range got.Items { + if item.PosterURL != "" { + t.Fatalf("item %s poster = %q, want none", item.ContentID, item.PosterURL) + } + } + }) + + t.Run("v1 preview body is unchanged and signs no posters", func(t *testing.T) { + signer.batches = nil + // A client cannot opt the frozen route into posters through the body. + req := httptest.NewRequest(http.MethodPost, "/api/v1/collections/preview", strings.NewReader(`{"query_definition":`+query+`,"limit":20,"WithPosters":true}`)) + req = req.WithContext(access.SetScope(req.Context(), scope)) + rec := httptest.NewRecorder() + h.HandlePreviewCollection(rec, req) + want := fmt.Sprintf(`{"items":[{"content_id":%q,"title":"Preview 1","type":"movie"},{"content_id":%q,"title":"Preview 3","type":"movie"}],"total":2}`+"\n", f.ids[1], f.ids[3]) + if rec.Code != http.StatusOK || rec.Body.String() != want { + t.Fatalf("v1 preview = %d %s, want %s", rec.Code, rec.Body.String(), want) + } + if len(signer.batches) != 0 { + t.Fatalf("v1 preview signed batches %v, want none", signer.batches) + } + }) +} diff --git a/internal/api/handlers/collections_service.go b/internal/api/handlers/collections_service.go index a77591e4e0..8b6576ec6c 100644 --- a/internal/api/handlers/collections_service.go +++ b/internal/api/handlers/collections_service.go @@ -6,10 +6,9 @@ import ( "net/http" "strings" - "golang.org/x/sync/errgroup" - "github.com/Silo-Server/silo-server/internal/catalog" "github.com/Silo-Server/silo-server/internal/collectionutil" + "github.com/Silo-Server/silo-server/internal/usercollections" "github.com/Silo-Server/silo-server/internal/userstore" ) @@ -39,48 +38,57 @@ const ( collectionFilterSeries = "series" ) -// ListPersonalCollections answers the profile's visible collections and the -// account's groups, as v1 GET /collections does. +// ListPersonalCollections answers the collections the profile may see, as +// v1 GET /collections does: its own in its order, then other profiles' shared +// collections grouped by owner. Personal collection groups no longer exist, +// so Groups is always empty. func (h *CollectionHandler) ListPersonalCollections(ctx context.Context, userID int, profileID string) (PersonalCollectionListView, error) { var none PersonalCollectionListView store, err := h.storeProvider.ForUser(ctx, userID) if err != nil { return none, apiError(http.StatusInternalServerError, "internal_error", "Failed to access user store") } - - collectionsCh := make(chan []userstore.Collection, 1) - groupsCh := make(chan []userstore.CollectionGroup, 1) - eg, egCtx := errgroup.WithContext(ctx) - eg.Go(func() error { - collections, err := store.ListCollections(egCtx, profileID) - if err != nil { - return err - } - collectionsCh <- collections - return nil - }) - eg.Go(func() error { - groups, err := store.ListCollectionGroups(egCtx) - if err != nil { - return err - } - groupsCh <- groups - return nil - }) - if err := eg.Wait(); err != nil { + collections, err := store.ListCollections(ctx, profileID) + if err != nil { return none, apiError(http.StatusInternalServerError, "internal_error", "Failed to list collections") } - collections := <-collectionsCh - groups := <-groupsCh + return PersonalCollectionListView{ + Collections: h.collectionViews(ctx, store, userID, profileID, collections), + Groups: []CollectionGroupView{}, + }, nil +} - resp := PersonalCollectionListView{ - Collections: h.collectionViews(ctx, store, userID, collections), - Groups: make([]CollectionGroupView, 0, len(groups)), +// PersonalCollectionsHoldingItem returns the ids of profileID's own manual +// collections that hold itemID. It returns none when the request's viewer +// cannot access the title, so the answer never reveals that a hidden title +// exists or which collections still hold it. +func (h *CollectionHandler) PersonalCollectionsHoldingItem(ctx context.Context, userID int, profileID, itemID string) (map[string]bool, error) { + if profileID == "" || itemID == "" { + return map[string]bool{}, nil + } + if err := h.requireVisibleCollectionItem(ctx, itemID); err != nil { + if apiErr, ok := errors.AsType[*APIError](err); ok && apiErr.Status == http.StatusNotFound { + return map[string]bool{}, nil + } + return nil, err } - for _, g := range groups { - resp.Groups = append(resp.Groups, collectionGroupView(g)) + store, err := h.storeProvider.ForUser(ctx, userID) + if err != nil { + return nil, apiError(http.StatusInternalServerError, "internal_error", "Failed to access user store") + } + reader, ok := store.(userstore.CollectionMembershipReader) + if !ok { + return nil, apiError(http.StatusNotImplemented, "unsupported", "This store cannot report collection membership") + } + ids, err := reader.ManualCollectionsHolding(ctx, profileID, itemID) + if err != nil { + return nil, apiError(http.StatusInternalServerError, "internal_error", "Failed to read collection membership") + } + holding := make(map[string]bool, len(ids)) + for _, id := range ids { + holding[id] = true } - return resp, nil + return holding, nil } // Capabilities is the additive feature support collection clients detect. @@ -95,6 +103,7 @@ func (h *CollectionHandler) Capabilities() CollectionCapabilitiesView { CollectionSortPreferences: true, EffectiveCollectionSort: true, SortPreferenceKinds: sortPreferenceKinds, + PosterCollages: h.Collages != nil, } } @@ -117,6 +126,11 @@ func (h *CollectionHandler) CreatePersonalCollection(ctx context.Context, cmd Pe return none, err } } + if req.Description != "" { + if err := collectionFeatureError(store, "description"); err != nil { + return none, err + } + } queryDefinitionJSON := defaultJSON(req.QueryDefinition) collectionType := firstNonEmptyCollection(req.CollectionType, "manual") if collectionType == collectionTypeSmart { @@ -142,9 +156,9 @@ func (h *CollectionHandler) CreatePersonalCollection(ctx context.Context, cmd Pe collection, err := store.CreateCollection(ctx, userstore.CreateCollectionInput{ CreatorProfileID: cmd.ProfileID, Name: req.Name, + Description: req.Description, CollectionType: collectionType, IsShared: req.IsShared, - AllowedProfileIDs: req.AllowedProfileIDs, QueryDefinition: queryDefinition, SortConfig: sortConfig, DisplayQueryDefinition: displayQueryDefinition, @@ -163,12 +177,14 @@ func (h *CollectionHandler) CreatePersonalCollection(ctx context.Context, cmd Pe collection = refreshed } } - return h.collectionView(ctx, store, cmd.UserID, *collection), nil + h.refreshCollage(ctx, store, cmd.UserID, collection) + return h.collectionView(ctx, store, cmd.UserID, cmd.ProfileID, *collection) } -// ReorderPersonalCollections replaces the order of one group's collections. -// orderedIDs must name every collection in scope exactly once. -func (h *CollectionHandler) ReorderPersonalCollections(ctx context.Context, userID int, profileID string, groupID *string, orderedIDs []string) error { +// ReorderPersonalCollections replaces the order of the profile's own +// collections. orderedIDs must name each of them exactly once; another +// profile's collection, even a shared one, is a validation failure. +func (h *CollectionHandler) ReorderPersonalCollections(ctx context.Context, userID int, profileID string, orderedIDs []string) error { store, err := h.storeProvider.ForUser(ctx, userID) if err != nil { return apiError(http.StatusInternalServerError, "internal_error", "Failed to access user store") @@ -176,12 +192,12 @@ func (h *CollectionHandler) ReorderPersonalCollections(ctx context.Context, user if err := collectionFeatureError(store, "item_reorder"); err != nil { return err } - if err := reorderCollectionsWithRevision(ctx, store, profileID, groupID, orderedIDs); err != nil { + if err := reorderCollectionsWithRevision(ctx, store, profileID, orderedIDs); err != nil { if errors.Is(err, userstore.ErrCollectionRevisionMismatch) { return err } if errors.Is(err, collectionutil.ErrOrderedIDsMismatch) { - return fieldError("ordered_ids", "ordered_ids must include every visible collection in the group exactly once") + return fieldError("ordered_ids", "ordered_ids must name each of your own collections exactly once") } if strings.Contains(err.Error(), "ordered_ids contains duplicates") { return fieldError("ordered_ids", "ordered_ids contains duplicates") @@ -293,31 +309,60 @@ func (h *CollectionHandler) ReorderCollectionGroups(ctx context.Context, userID } // collectionViews renders stored collections with their posters presigned and -// item_count set to the members the acting profile can see. -func (h *CollectionHandler) collectionViews(ctx context.Context, store userstore.UserStore, userID int, collections []userstore.Collection) []PersonalCollectionView { - var counts map[string]int +// item_count set to the members profileID can see: for another profile's +// collection, only those its owner can access too. On /api/v2 a collection +// without an uploaded or imported poster shows its collage for that profile, +// once built. A collection whose owner cannot be resolved is left out. +func (h *CollectionHandler) collectionViews(ctx context.Context, store userstore.UserStore, userID int, profileID string, collections []userstore.Collection) []PersonalCollectionView { + var reads ownedCollectionReads if userstore.HasCatalogSQLState(store) { - sources := make([]catalog.PersonalCollectionDefinition, 0, len(collections)) + sources := make([]ownedCollectionDefinition, 0, len(collections)) for _, c := range collections { - sources = append(sources, catalog.PersonalCollectionDefinition{ID: c.ID, CollectionType: c.CollectionType, QueryDefinition: c.QueryDefinition, DisplayQueryDefinition: c.DisplayQueryDefinition}) + sources = append(sources, ownedCollectionDefinition{ + PersonalCollectionDefinition: usercollections.CollectionDefinition(c), + CreatorProfileID: c.CreatorProfileID, + WantsCollage: strings.TrimSpace(c.PosterURL) == "", + }) } - counts = visiblePersonalCollectionCounts(ctx, h.Executor, userID, sources, AccessFilterFromContext(ctx, "")) + reads = ownerScopedCollectionReads(ctx, h.Executor, h.CollectionOwners, collagesForRead(ctx, h.Collages), userID, profileID, sources, AccessFilterFromContext(ctx, "")) } views := make([]PersonalCollectionView, 0, len(collections)) for _, c := range collections { - if n, ok := counts[c.ID]; ok { + if reads.unavailable[c.ID] { + continue + } + if n, ok := reads.counts[c.ID]; ok { c.ItemCount = n } resp := toCollectionResponse(c) - resp.PosterURL = h.presignUserCollectionPoster(ctx, c.PosterURL) + posterPath := c.PosterURL + if collage, ok := reads.posters[c.ID]; ok { + posterPath, resp.PosterThumbhash, resp.PosterIsCollage = collage.Path, collage.Thumbhash, true + } + resp.PosterURL = h.presignUserCollectionPoster(ctx, posterPath) views = append(views, resp) } return views } -// collectionView renders one stored collection as collectionViews does. -func (h *CollectionHandler) collectionView(ctx context.Context, store userstore.UserStore, userID int, c userstore.Collection) PersonalCollectionView { - return h.collectionViews(ctx, store, userID, []userstore.Collection{c})[0] +// collagesForRead is the collage service a read of personal collections uses: +// /api/v2 reads show collages, while the frozen /api/v1 bridge keeps showing +// only uploaded and imported posters. +func collagesForRead(ctx context.Context, collages *catalog.PersonalCollectionCollages) *catalog.PersonalCollectionCollages { + if !isNativeAPIV2(ctx) { + return nil + } + return collages +} + +// collectionView renders one stored collection as collectionViews does, and +// fails when its owner's access cannot be resolved. +func (h *CollectionHandler) collectionView(ctx context.Context, store userstore.UserStore, userID int, profileID string, c userstore.Collection) (PersonalCollectionView, error) { + views := h.collectionViews(ctx, store, userID, profileID, []userstore.Collection{c}) + if len(views) == 0 { + return PersonalCollectionView{}, apiError(http.StatusInternalServerError, "internal_error", "Failed to load collection") + } + return views[0], nil } func collectionGroupView(g userstore.CollectionGroup) CollectionGroupView { @@ -345,10 +390,14 @@ func (h *CollectionHandler) PersonalCollectionFeatures(ctx context.Context, user if err != nil { return userstore.CollectionFeatures{}, apiError(500, "internal_error", "Failed to access user store") } - if features, ok := store.(userstore.CollectionFeatureProvider); ok { - return features.CollectionFeatures(), nil + features := userstore.CollectionFeatures{} + if provider, ok := store.(userstore.CollectionFeatureProvider); ok { + features = provider.CollectionFeatures() } - return userstore.CollectionFeatures{}, nil + // Without artwork storage no poster can be uploaded or composed, as + // server collections report it. + features.Artwork = features.Artwork && h.ArtworkStore != nil + return features, nil } func collectionFeatureError(store userstore.UserStore, feature string) error { @@ -367,6 +416,8 @@ func collectionFeatureError(store userstore.UserStore, feature string) error { supported = f.Artwork case "item_reorder": supported = f.ItemReorder + case "description": + supported = f.Description } if !supported { return apiError(http.StatusNotImplemented, "unsupported", "The acting account does not support collection "+feature) diff --git a/internal/api/handlers/collections_sync_schedule_test.go b/internal/api/handlers/collections_sync_schedule_test.go new file mode 100644 index 0000000000..f1ce9c58d1 --- /dev/null +++ b/internal/api/handlers/collections_sync_schedule_test.go @@ -0,0 +1,344 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "slices" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/usercollections" + "github.com/Silo-Server/silo-server/internal/userstore" + "github.com/Silo-Server/silo-server/internal/userstore/pgstore" +) + +// unrestrictedOwners lets every collection owner access the whole catalog. +type unrestrictedOwners struct{} + +func (unrestrictedOwners) OwnerFilter(context.Context, int, string) (catalog.AccessFilter, error) { + return catalog.AccessFilter{}, nil +} + +// heldTMDBList is a TMDB list source that reports each fetch on started and +// answers an empty list when release receives, so a test can act while a +// sync is in flight. +type heldTMDBList struct { + started chan struct{} + release chan struct{} +} + +func (l heldTMDBList) GetList(ctx context.Context, _, _ int) ([]catalog.TMDBCollectionEntry, error) { + l.started <- struct{}{} + select { + case <-l.release: + return nil, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +// TestPersonalCollectionSyncScheduleDB covers the schedule a /api/v2 update +// sets on a synced list: named cadences only, off with "", refused on manual +// and smart collections, ignored by the frozen /api/v1 update, kept when it +// lands during a sync, and #193 S2's isolation from other profiles and logins. +func TestPersonalCollectionSyncScheduleDB(t *testing.T) { + f := newPagingIntegrationFixture(t) + ctx := t.Context() + provider := pgstore.NewPostgresProvider(f.pool) + store, err := provider.ForUser(ctx, f.account) + if err != nil { + t.Fatal(err) + } + for _, id := range []string{"owner", "viewer"} { + if err := store.CreateProfile(ctx, userstore.Profile{ID: id, Name: id}); err != nil { + t.Fatal(err) + } + } + var otherLogin int + if err := f.pool.QueryRow(ctx, `INSERT INTO users(username,role) VALUES($1,'user') RETURNING id`, fmt.Sprintf("sync-schedule-%d", time.Now().UnixNano())).Scan(&otherLogin); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = f.pool.Exec(context.Background(), `DELETE FROM users WHERE id=$1`, otherLogin) }) + h := NewCollectionHandler(provider) + daily := usercollections.AllowedSyncSchedules["daily"] + + createSynced := func(t *testing.T, shared bool) *userstore.Collection { + t.Helper() + next := time.Now().Add(12 * time.Hour).UTC().Truncate(time.Microsecond) + c, err := store.CreateCollection(ctx, userstore.CreateCollectionInput{ + CreatorProfileID: "owner", Name: "Synced", CollectionType: "tmdb", QueryDefinition: "{}", + IsShared: shared, + SourceURL: "https://www.themoviedb.org/list/310", + SourceConfig: `{"mode":"tmdb_list","url":"https://www.themoviedb.org/list/310"}`, + SyncSchedule: &daily, NextSyncAt: &next, + }) + if err != nil { + t.Fatal(err) + } + return c + } + stored := func(t *testing.T, id string) *userstore.Collection { + t.Helper() + c, err := store.GetCollection(ctx, id) + if err != nil { + t.Fatal(err) + } + return c + } + setSchedule := func(profileID, id, schedule string) (PersonalCollectionView, error) { + return h.UpdatePersonalCollection(ctx, PersonalCollectionUpdateCommand{UserID: f.account, ProfileID: profileID, CollectionID: id, Request: PersonalCollectionUpdateRequest{SyncSchedule: &schedule}}) + } + requireStatus := func(t *testing.T, err error, status int, field string) { + t.Helper() + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.Status != status || apiErr.Field != field { + t.Fatalf("error = %v, want status %d on %q", err, status, field) + } + } + requireUnchanged := func(t *testing.T, before *userstore.Collection) { + t.Helper() + after := stored(t, before.ID) + if !equalStringPointers(after.SyncSchedule, before.SyncSchedule) || !equalTimePointers(after.NextSyncAt, before.NextSyncAt) { + t.Fatalf("schedule %v next %v, want %v next %v", after.SyncSchedule, after.NextSyncAt, before.SyncSchedule, before.NextSyncAt) + } + } + + t.Run("a named cadence sets the schedule and its next run", func(t *testing.T) { + c := createSynced(t, false) + for _, tc := range []struct { + cadence string + within time.Duration + matches func(time.Time) bool + }{ + {"weekly", 7 * 24 * time.Hour, func(local time.Time) bool { return local.Weekday() == time.Sunday }}, + {"monthly", 31 * 24 * time.Hour, func(local time.Time) bool { return local.Day() == 1 }}, + {"daily", 24 * time.Hour, func(time.Time) bool { return true }}, + } { + before := time.Now() + view, err := setSchedule("owner", c.ID, tc.cadence) + if err != nil { + t.Fatalf("%s: %v", tc.cadence, err) + } + want := usercollections.AllowedSyncSchedules[tc.cadence] + got := stored(t, c.ID) + if view.SyncSchedule != want || got.SyncSchedule == nil || *got.SyncSchedule != want { + t.Fatalf("%s: view schedule %q, stored %v; want %q", tc.cadence, view.SyncSchedule, got.SyncSchedule, want) + } + // The next run is the schedule's next 04:30 on the node's clock, + // plus up to 15 minutes of jitter. + next := got.NextSyncAt + if next == nil || view.NextSyncAt == "" || !next.After(before) || next.After(before.Add(tc.within+15*time.Minute)) { + t.Fatalf("%s: next_sync_at = %v (view %q), want within %s of %v", tc.cadence, next, view.NextSyncAt, tc.within, before) + } + local := next.In(time.Local) + if minutes := local.Hour()*60 + local.Minute(); minutes < 4*60+30 || minutes >= 4*60+45 || !tc.matches(local) { + t.Fatalf("%s: next_sync_at %v is not the schedule's next run", tc.cadence, local) + } + } + }) + + t.Run("an empty schedule turns syncing off", func(t *testing.T) { + c := createSynced(t, false) + view, err := setSchedule("owner", c.ID, "") + if err != nil { + t.Fatal(err) + } + got := stored(t, c.ID) + if view.SyncSchedule != "" || view.NextSyncAt != "" || got.SyncSchedule != nil || got.NextSyncAt != nil { + t.Fatalf("view %q %q, stored %v %v; want syncing off", view.SyncSchedule, view.NextSyncAt, got.SyncSchedule, got.NextSyncAt) + } + }) + + t.Run("a cron expression is refused", func(t *testing.T) { + c := createSynced(t, false) + for _, expr := range []string{daily, "*/15 * * * *", "hourly"} { + _, err := setSchedule("owner", c.ID, expr) + requireStatus(t, err, http.StatusBadRequest, "sync_schedule") + requireUnchanged(t, c) + } + }) + + t.Run("manual and smart collections take no schedule", func(t *testing.T) { + for _, kind := range []string{"manual", "smart"} { + c, err := store.CreateCollection(ctx, userstore.CreateCollectionInput{CreatorProfileID: "owner", Name: kind, CollectionType: kind, QueryDefinition: "{}"}) + if err != nil { + t.Fatal(err) + } + _, err = setSchedule("owner", c.ID, "daily") + requireStatus(t, err, http.StatusBadRequest, "sync_schedule") + requireUnchanged(t, c) + } + }) + + v1 := func(t *testing.T, method, target, body string, handle http.HandlerFunc) []byte { + t.Helper() + req := httptest.NewRequest(method, target, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + reqCtx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: f.account}) + reqCtx = apimw.SetProfileID(reqCtx, "owner") + routeCtx := chi.NewRouteContext() + if i := strings.LastIndex(target, "/"); method == http.MethodPut { + routeCtx.URLParams.Add("id", target[i+1:]) + } + reqCtx = context.WithValue(reqCtx, chi.RouteCtxKey, routeCtx) + rec := httptest.NewRecorder() + handle(rec, req.WithContext(reqCtx)) + if rec.Code != http.StatusOK { + t.Fatalf("v1 %s %s: %d %s", method, target, rec.Code, rec.Body.String()) + } + return rec.Body.Bytes() + } + // The members the frozen /api/v1 collection view has always had; a key + // outside this set would change its bytes. + v1Keys := []string{"id", "profile_id", "creator_profile_id", "name", "description", "collection_type", "is_shared", + "allowed_profile_ids", "query_definition", "sort_config", "sort_order", "group_id", "source_url", "source_config", + "sync_schedule", "next_sync_at", "last_sync_at", "last_sync_status", "last_sync_message", "display_query_definition", + "item_count", "include_in_server_collections", "poster_url", "poster_thumbhash", "created_at", "updated_at"} + requireV1View := func(t *testing.T, raw json.RawMessage) { + t.Helper() + var members map[string]json.RawMessage + if err := json.Unmarshal(raw, &members); err != nil { + t.Fatal(err) + } + for key := range members { + if !slices.Contains(v1Keys, key) { + t.Fatalf("v1 collection view has %q: %s", key, raw) + } + } + if string(members["sync_schedule"]) != `"`+daily+`"` { + t.Fatalf("v1 sync_schedule = %s, want %q", members["sync_schedule"], daily) + } + } + + t.Run("v1 update ignores sync_schedule", func(t *testing.T) { + c := createSynced(t, false) + body := v1(t, http.MethodPut, "/api/v1/collections/"+c.ID, `{"name":"Renamed","sync_schedule":"weekly"}`, h.HandleUpdateCollection) + requireV1View(t, body) + if got := stored(t, c.ID); got.Name != "Renamed" { + t.Fatalf("name = %q, want the v1 update applied", got.Name) + } + requireUnchanged(t, c) + }) + + t.Run("v1 list carries no cadence", func(t *testing.T) { + c := createSynced(t, false) + var list struct { + Collections []json.RawMessage `json:"collections"` + } + if err := json.Unmarshal(v1(t, http.MethodGet, "/api/v1/collections", "", h.HandleListCollections), &list); err != nil { + t.Fatal(err) + } + found := false + for _, raw := range list.Collections { + var id struct{ ID string } + _ = json.Unmarshal(raw, &id) + if id.ID == c.ID { + found = true + requireV1View(t, raw) + } + } + if !found { + t.Fatalf("v1 list lacks %s", c.ID) + } + }) + + t.Run("an edit made during a sync wins", func(t *testing.T) { + source := heldTMDBList{started: make(chan struct{}), release: make(chan struct{})} + svc := usercollections.NewService(provider, catalog.NewItemRepository(f.pool), catalog.NewLibraryItemRepository(f.pool), unrestrictedOwners{}, nil, slog.New(slog.DiscardHandler)) + svc.TMDBLists = source + // syncWhile runs a sync and applies edit while the sync is between + // reading the collection and recording its result. + syncWhile := func(t *testing.T, id string, edit func()) *userstore.Collection { + t.Helper() + done := make(chan error, 1) + go func() { + _, err := svc.SyncCollection(ctx, f.account, id) + done <- err + }() + select { + case <-source.started: + case err := <-done: + t.Fatalf("sync finished before fetching: %v", err) + } + edit() + source.release <- struct{}{} + if err := <-done; err != nil { + t.Fatal(err) + } + got := stored(t, id) + if got.LastSyncAt == nil || got.LastSyncStatus != "success" { + t.Fatalf("sync result not recorded: last %v status %q", got.LastSyncAt, got.LastSyncStatus) + } + return got + } + + c := createSynced(t, false) + got := syncWhile(t, c.ID, func() { + if _, err := setSchedule("owner", c.ID, ""); err != nil { + t.Fatal(err) + } + }) + if got.SyncSchedule != nil || got.NextSyncAt != nil { + t.Fatalf("daily to off during a sync: schedule %v next %v, want both null", got.SyncSchedule, got.NextSyncAt) + } + + c = createSynced(t, false) + var edited *userstore.Collection + got = syncWhile(t, c.ID, func() { + if _, err := setSchedule("owner", c.ID, "weekly"); err != nil { + t.Fatal(err) + } + edited = stored(t, c.ID) + }) + if got.SyncSchedule == nil || *got.SyncSchedule != usercollections.AllowedSyncSchedules["weekly"] || !equalTimePointers(got.NextSyncAt, edited.NextSyncAt) { + t.Fatalf("daily to weekly during a sync: schedule %v next %v, want weekly next %v", got.SyncSchedule, got.NextSyncAt, edited.NextSyncAt) + } + + // Without an edit, the sync moves the next run on as before. + got = syncWhile(t, c.ID, func() {}) + if got.NextSyncAt == nil || !got.NextSyncAt.After(time.Now()) { + t.Fatalf("unedited sync: next %v", got.NextSyncAt) + } + }) + + // #193 S2: only the creator changes a collection's schedule. A profile + // that can see a shared collection is refused, one that can't see a + // private collection and another login don't find it, and the row stays + // as it was. + t.Run("only the creator changes the schedule", func(t *testing.T) { + shared := createSynced(t, true) + _, err := setSchedule("viewer", shared.ID, "weekly") + requireStatus(t, err, http.StatusForbidden, "") + requireUnchanged(t, shared) + + private := createSynced(t, false) + _, err = setSchedule("viewer", private.ID, "weekly") + requireStatus(t, err, http.StatusNotFound, "") + weekly := "weekly" + for _, id := range []string{private.ID, shared.ID} { + _, err = h.UpdatePersonalCollection(ctx, PersonalCollectionUpdateCommand{UserID: otherLogin, ProfileID: "owner", CollectionID: id, Request: PersonalCollectionUpdateRequest{SyncSchedule: &weekly}}) + requireStatus(t, err, http.StatusNotFound, "") + } + requireUnchanged(t, private) + requireUnchanged(t, shared) + }) +} + +func equalStringPointers(a, b *string) bool { + return (a == nil) == (b == nil) && (a == nil || *a == *b) +} + +func equalTimePointers(a, b *time.Time) bool { + return (a == nil) == (b == nil) && (a == nil || a.Equal(*b)) +} diff --git a/internal/api/handlers/collections_v1_bridge_test.go b/internal/api/handlers/collections_v1_bridge_test.go new file mode 100644 index 0000000000..0defd0acf8 --- /dev/null +++ b/internal/api/handlers/collections_v1_bridge_test.go @@ -0,0 +1,141 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "slices" + "strings" + "testing" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/userstore" +) + +// v1BridgeStore is a login with three profiles, one shared and one private +// collection, and the PostgreSQL feature set after #1615 (no groups). +type v1BridgeStore struct { + lifecycleStore + collections []userstore.Collection + reordered []string +} + +func (s *v1BridgeStore) ListCollections(_ context.Context, profileID string) ([]userstore.Collection, error) { + var out []userstore.Collection + for _, c := range s.collections { + if c.VisibleTo(profileID) { + out = append(out, c) + } + } + return out, nil +} +func (s *v1BridgeStore) ListProfiles(context.Context) ([]userstore.Profile, error) { + return []userstore.Profile{{ID: "p3"}, {ID: "owner"}, {ID: "p2"}}, nil +} +func (s *v1BridgeStore) CollectionFeatures() userstore.CollectionFeatures { + return userstore.CollectionFeatures{Imports: true, Artwork: true, ItemReorder: true} +} +func (s *v1BridgeStore) ReorderCollections(_ context.Context, _ string, ids []string) error { + s.reordered = ids + return nil +} + +type v1BridgeProvider struct { + userstore.UserStoreProvider + store *v1BridgeStore +} + +func (p v1BridgeProvider) ForUser(context.Context, int) (userstore.UserStore, error) { + return p.store, nil +} + +func v1BridgeRequest(method, target, body, profileID string) *http.Request { + req := httptest.NewRequest(method, target, strings.NewReader(body)) + ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 1}) + return req.WithContext(apimw.SetProfileID(ctx, profileID)) +} + +// The /api/v1 bridge keeps its response shape: allowed_profile_ids reports +// the effective audience (the creator alone, or every profile on the login), +// group_id is always null and groups is always empty. +func TestV1ListCollectionsReportsEffectiveAudience(t *testing.T) { + store := &v1BridgeStore{collections: []userstore.Collection{ + {ID: "shared", CreatorProfileID: "owner", IsShared: true, CollectionType: "manual"}, + {ID: "private", CreatorProfileID: "owner", CollectionType: "manual"}, + }} + h := NewCollectionHandler(v1BridgeProvider{store: store}) + + for profileID, want := range map[string]map[string][]string{ + "owner": {"shared": {"owner", "p2", "p3"}, "private": {"owner"}}, + "p2": {"shared": {"owner", "p2", "p3"}}, + } { + rec := httptest.NewRecorder() + h.HandleListCollections(rec, v1BridgeRequest(http.MethodGet, "/api/v1/collections", "", profileID)) + if rec.Code != http.StatusOK { + t.Fatalf("%s: status %d: %s", profileID, rec.Code, rec.Body) + } + var body struct { + Collections []struct { + ID string `json:"id"` + AllowedProfileIDs []string `json:"allowed_profile_ids"` + GroupID *string `json:"group_id"` + } `json:"collections"` + Groups []json.RawMessage `json:"groups"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Groups == nil || len(body.Groups) != 0 { + t.Fatalf("%s: groups = %s, want []", profileID, rec.Body) + } + got := map[string][]string{} + for _, c := range body.Collections { + if c.GroupID != nil { + t.Fatalf("%s: %s group_id = %q, want null", profileID, c.ID, *c.GroupID) + } + got[c.ID] = c.AllowedProfileIDs + } + if len(got) != len(want) { + t.Fatalf("%s: listed %v, want %v", profileID, got, want) + } + for id, audience := range want { + if !slices.Equal(got[id], audience) { + t.Fatalf("%s: %s allowed_profile_ids = %v, want %v", profileID, id, got[id], audience) + } + } + } +} + +// v1 group writes answer as on an account without group support, and a +// group-scoped order is refused; the flat order still works. +func TestV1CollectionGroupsAreUnsupported(t *testing.T) { + store := &v1BridgeStore{} + h := NewCollectionHandler(v1BridgeProvider{store: store}) + for name, call := range map[string]func(*httptest.ResponseRecorder){ + "create": func(w *httptest.ResponseRecorder) { + h.HandleCreateCollectionGroup(w, v1BridgeRequest(http.MethodPost, "/", `{"name":"G"}`, "owner")) + }, + "order": func(w *httptest.ResponseRecorder) { + h.HandleReorderCollectionGroups(w, v1BridgeRequest(http.MethodPut, "/", `{"ordered_ids":[]}`, "owner")) + }, + } { + rec := httptest.NewRecorder() + call(rec) + if rec.Code != http.StatusNotImplemented { + t.Errorf("%s: status %d, want 501: %s", name, rec.Code, rec.Body) + } + } + + rec := httptest.NewRecorder() + h.HandleReorderCollections(rec, v1BridgeRequest(http.MethodPut, "/", `{"ordered_ids":["a"],"group_id":"g1"}`, "owner")) + if rec.Code != http.StatusBadRequest || store.reordered != nil { + t.Fatalf("group-scoped order: status %d, reordered %v", rec.Code, store.reordered) + } + rec = httptest.NewRecorder() + h.HandleReorderCollections(rec, v1BridgeRequest(http.MethodPut, "/", `{"ordered_ids":["a"],"group_id":null}`, "owner")) + if rec.Code != http.StatusNoContent || !slices.Equal(store.reordered, []string{"a"}) { + t.Fatalf("flat order: status %d, reordered %v: %s", rec.Code, store.reordered, rec.Body) + } +} diff --git a/internal/api/handlers/home_missing_collection_test.go b/internal/api/handlers/home_missing_collection_test.go new file mode 100644 index 0000000000..17de33d5a8 --- /dev/null +++ b/internal/api/handlers/home_missing_collection_test.go @@ -0,0 +1,203 @@ +package handlers + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strconv" + "sync" + "testing" + + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/sections" + "github.com/Silo-Server/silo-server/internal/userstore" + "github.com/Silo-Server/silo-server/internal/userstore/pgstore" +) + +// levelLogRecorder captures the level and message of every log record written +// while a test runs. +type levelLogRecorder struct { + mu sync.Mutex + records []slog.Record +} + +func (r *levelLogRecorder) Enabled(context.Context, slog.Level) bool { return true } +func (r *levelLogRecorder) Handle(_ context.Context, record slog.Record) error { + r.mu.Lock() + defer r.mu.Unlock() + r.records = append(r.records, record.Clone()) + return nil +} +func (r *levelLogRecorder) WithAttrs([]slog.Attr) slog.Handler { return r } +func (r *levelLogRecorder) WithGroup(string) slog.Handler { return r } + +func (r *levelLogRecorder) take(level slog.Level) []string { + r.mu.Lock() + defer r.mu.Unlock() + var out []string + for _, record := range r.records { + if record.Level == level { + out = append(out, record.Message) + } + } + return out +} + +func (r *levelLogRecorder) reset() { + r.mu.Lock() + defer r.mu.Unlock() + r.records = nil +} + +// brokenCollectionStore fails to read one collection the way a database outage +// would, so the test can tell that failure apart from a deleted collection. +type brokenCollectionStore struct { + userstore.UserStore + brokenID string +} + +func (s brokenCollectionStore) GetCollection(ctx context.Context, id string) (*userstore.Collection, error) { + if id == s.brokenID { + return nil, errors.New("connection reset by peer") + } + return s.UserStore.GetCollection(ctx, id) +} + +// TestSectionRowOfDeletedCollectionLogsAtDebugDB pins that the single-row Home +// and library reads answer an empty row, with a debug record instead of an +// ERROR, when the row's server or personal collection was deleted. Any other +// failure still logs an ERROR, and a private collection's row on another +// profile's Home shows nothing (#193 S1). +func TestSectionRowOfDeletedCollectionLogsAtDebugDB(t *testing.T) { + f := newPagingIntegrationFixture(t) + ctx := t.Context() + + libraryCollections := catalog.NewLibraryCollectionRepository(f.pool) + server, err := libraryCollections.Create(ctx, catalog.CreateLibraryCollectionInput{ + LibraryIDs: []int{f.library}, Title: "Deleted", Slug: fmt.Sprintf("deleted-row-%d", f.account), CollectionType: "manual", + }) + if err != nil { + t.Fatal(err) + } + if err := libraryCollections.Delete(ctx, server.ID); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = f.pool.Exec(context.Background(), `DELETE FROM library_collection_revisions WHERE collection_id=$1`, server.ID) + }) + + store, err := pgstore.NewPostgresProvider(f.pool).ForUser(ctx, f.account) + if err != nil { + t.Fatal(err) + } + for _, id := range []string{"owner", "other"} { + if err := store.CreateProfile(ctx, userstore.Profile{ID: id, Name: id}); err != nil { + t.Fatal(err) + } + } + create := func(name string) *userstore.Collection { + t.Helper() + c, err := store.CreateCollection(ctx, userstore.CreateCollectionInput{CreatorProfileID: "owner", Name: name, CollectionType: "manual"}) + if err != nil { + t.Fatal(err) + } + if err := store.AddCollectionItem(ctx, c.ID, f.ids[1], 0); err != nil { + t.Fatal(err) + } + return c + } + deleted := create("Deleted") + if err := store.DeleteCollection(ctx, deleted.ID); err != nil { + t.Fatal(err) + } + broken := create("Broken") + private := create("Private") + + saveRows := func(profileID, scope, scopeID string, configs map[string]string) { + t.Helper() + rows := make([]userstore.SectionOverride, 0, len(configs)) + position := 1000 + for id, config := range configs { + position++ + limit := 20 + rows = append(rows, userstore.SectionOverride{ + ID: id, Scope: scope, Position: &position, ItemLimit: &limit, IsUserAdded: true, + UserSectionType: string(sections.SectionCollection), UserTitle: id, UserConfig: config, + }) + } + if err := store.SaveSectionOverrides(ctx, profileID, scope, scopeID, rows); err != nil { + t.Fatal(err) + } + } + serverRow := fmt.Sprintf(`{"library_collection_id":%q}`, server.ID) + saveRows("owner", "home", "", map[string]string{ + "row-server": serverRow, + "row-personal": fmt.Sprintf(`{"user_collection_id":%q}`, deleted.ID), + "row-broken": fmt.Sprintf(`{"user_collection_id":%q}`, broken.ID), + }) + saveRows("owner", "library", strconv.Itoa(f.library), map[string]string{"row-server": serverRow}) + saveRows("other", "home", "", map[string]string{"row-private": fmt.Sprintf(`{"user_collection_id":%q}`, private.ID)}) + + provider := pagingIntegrationProvider{account: f.account, store: brokenCollectionStore{UserStore: store, brokenID: broken.ID}} + fetcher := sections.NewFetcher(f.pool) + fetcher.CollectionRepo = libraryCollections + fetcher.StoreProvider = provider + h := NewSectionHandler(sections.NewRepository(f.pool), fetcher) + h.StoreProvider = provider + viewerContext := func(profileID string) context.Context { + reqCtx := apimw.SetClaims(ctx, &auth.Claims{UserID: f.account}) + return apimw.SetProfileID(reqCtx, profileID) + } + viewer := SectionViewer{Access: catalog.AccessFilter{UserID: f.account}} + + logs := &levelLogRecorder{} + previous := slog.Default() + slog.SetDefault(slog.New(logs)) + t.Cleanup(func() { slog.SetDefault(previous) }) + + assertEmpty := func(t *testing.T, row SectionView, err error, id string) { + t.Helper() + if err != nil { + t.Fatalf("%s: %v", id, err) + } + if row.ID != id || row.Items == nil || len(row.Items) != 0 || row.TotalCount != 0 { + t.Fatalf("%s = %+v, want the row with no items", id, row) + } + } + for _, tc := range []struct { + name, profile, id string + library bool + wantError bool + wantDebug int + }{ + {name: "deleted server collection on Home", profile: "owner", id: "row-server", wantDebug: 1}, + {name: "deleted personal collection on Home", profile: "owner", id: "row-personal", wantDebug: 1}, + {name: "deleted server collection in a library", profile: "owner", id: "row-server", library: true, wantDebug: 1}, + {name: "unrelated failure", profile: "owner", id: "row-broken", wantError: true}, + {name: "private collection on another profile", profile: "other", id: "row-private"}, + } { + t.Run(tc.name, func(t *testing.T) { + logs.reset() + var row SectionView + if tc.library { + row, err = h.LibrarySectionItems(viewerContext(tc.profile), f.library, tc.id, viewer) + } else { + row, err = h.HomeSectionItems(viewerContext(tc.profile), tc.id, viewer) + } + assertEmpty(t, row, err, tc.id) + errs := logs.take(slog.LevelError) + if tc.wantError && (len(errs) != 1 || errs[0] != "fetching section items") { + t.Fatalf("ERROR records = %q, want one fetching section items record", errs) + } + if !tc.wantError && len(errs) != 0 { + t.Fatalf("ERROR records = %q, want none", errs) + } + if debug := logs.take(slog.LevelDebug); len(debug) != tc.wantDebug { + t.Fatalf("DEBUG records = %q, want %d", debug, tc.wantDebug) + } + }) + } +} diff --git a/internal/api/handlers/home_views_service.go b/internal/api/handlers/home_views_service.go index 2000ccd3aa..303e28280c 100644 --- a/internal/api/handlers/home_views_service.go +++ b/internal/api/handlers/home_views_service.go @@ -77,7 +77,7 @@ func (h *SectionHandler) HomeSectionItems(ctx context.Context, sectionID string, } withItems, fetchErr := h.fetcher.FetchOne(ctx, homeSectionsForFetch([]sections.ResolvedSection{s}, hideWatched)[0], nil, libraryIDs, userID, profileID, accessFilter) if fetchErr != nil { - slog.ErrorContext(ctx, "fetching section items", "component", "api", "section_id", s.ID, "type", s.SectionType, "error", fetchErr) + sections.LogFetchError(ctx, "api", s, fetchErr) withItems = sections.SectionWithItems{ ResolvedSection: s, Items: []*models.MediaItem{}, diff --git a/internal/api/handlers/items.go b/internal/api/handlers/items.go index bd65254a19..486a488730 100644 --- a/internal/api/handlers/items.go +++ b/internal/api/handlers/items.go @@ -220,6 +220,15 @@ func (h *ItemsHandler) SetCatalogSearchProvider(provider catalog.CatalogSearchPr h.catalogResolver.WithSearchProvider(provider) } +// SetPersonalCollectionAccess limits the catalog's reads of another +// profile's shared personal collection to its owner's access. +func (h *ItemsHandler) SetPersonalCollectionAccess(owners catalog.PersonalCollectionAccess) { + if h == nil || h.catalogResolver == nil || owners == nil { + return + } + h.catalogResolver.WithPersonalCollectionAccess(owners) +} + func (h *ItemsHandler) SetLocalWatchEventDispatcher(dispatcher LocalWatchEventDispatcher) { h.localWatchDispatcher = dispatcher } diff --git a/internal/api/handlers/libraries_user_collections_group_test.go b/internal/api/handlers/libraries_user_collections_group_test.go index ef0b673ffb..753d897bad 100644 --- a/internal/api/handlers/libraries_user_collections_group_test.go +++ b/internal/api/handlers/libraries_user_collections_group_test.go @@ -113,4 +113,39 @@ func TestCreateLibrarySeedsUserCollectionsGroupAndSurfacesPersonalCollectionDB(t if len(tab.Groups[0].Collections) != 1 || tab.Groups[0].Collections[0].ID != personal.ID { t.Fatalf("personal collections = %+v", tab.Groups[0].Collections) } + + // Another profile on the login sees the owner's shared, opted-in + // collection there, with its creator, and never a private one or an + // Audiobookshelf row. + viewerID := profileID + "-viewer" + if _, err := pool.Exec(t.Context(), ` + INSERT INTO user_profiles (id, user_id, name) VALUES ($1, $2, 'Viewer')`, viewerID, userID); err != nil { + t.Fatal(err) + } + shared, err := store.CreateCollection(t.Context(), userstore.CreateCollectionInput{ + CreatorProfileID: profileID, + Name: "Shared personal collection", + CollectionType: "manual", + IsShared: true, + QueryDefinition: fmt.Sprintf(`{"library_ids":[%d]}`, created.ID), + IncludeInServerCollections: true, + }) + if err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(t.Context(), ` + INSERT INTO user_personal_collections (id, user_id, profile_id, creator_profile_id, name, collection_type, is_shared, include_in_server_collections) + VALUES ($1, $2, $3, $3, 'Audiobookshelf playlist', 'playlist', TRUE, TRUE)`, fmt.Sprintf("abs-%d", suffix), userID, profileID); err != nil { + t.Fatal(err) + } + tab, err = collections.LibraryCollectionsTab(t.Context(), created.ID, userID, viewerID) + if err != nil { + t.Fatal(err) + } + if len(tab.Groups) != 1 || len(tab.Groups[0].Collections) != 1 { + t.Fatalf("viewer's user collections = %+v", tab.Groups) + } + if got := tab.Groups[0].Collections[0]; got.ID != shared.ID || got.CreatorProfileID == nil || *got.CreatorProfileID != profileID { + t.Fatalf("viewer's user collection = %+v, want %s by %s", got, shared.ID, profileID) + } } diff --git a/internal/api/handlers/library_collection_posters_test.go b/internal/api/handlers/library_collection_posters_test.go index 5ffe090133..c23b7c0902 100644 --- a/internal/api/handlers/library_collection_posters_test.go +++ b/internal/api/handlers/library_collection_posters_test.go @@ -99,3 +99,15 @@ func TestComposeCollectionCollageFailsWithoutEverySource(t *testing.T) { } } } + +// A library tab card marks the viewer's collage, and only that. +func TestLibraryTabCardsMarkViewerCollages(t *testing.T) { + h := &LibraryCollectionHandler{} + ctx := context.Background() + if card := h.libraryTabCardOf(ctx, &models.LibraryCollection{ID: "collage", PosterURL: "/collage.webp", PosterAutoGenerated: true}); !card.PosterIsCollage { + t.Fatalf("collage card = %+v, want PosterIsCollage", card) + } + if card := h.libraryTabCardOf(ctx, &models.LibraryCollection{ID: "uploaded", PosterURL: "/uploaded.webp"}); card.PosterIsCollage { + t.Fatalf("uploaded card = %+v, want no PosterIsCollage", card) + } +} diff --git a/internal/api/handlers/library_collections.go b/internal/api/handlers/library_collections.go index 0ef7569493..a9ea3422b7 100644 --- a/internal/api/handlers/library_collections.go +++ b/internal/api/handlers/library_collections.go @@ -8,7 +8,6 @@ import ( "io" "io/fs" "net/http" - neturl "net/url" "runtime/debug" "slices" "sort" @@ -26,11 +25,9 @@ import ( "github.com/Silo-Server/silo-server/internal/access" "github.com/Silo-Server/silo-server/internal/adminjob" apimw "github.com/Silo-Server/silo-server/internal/api/middleware" - "github.com/Silo-Server/silo-server/internal/artworkkey" "github.com/Silo-Server/silo-server/internal/artworkurl" "github.com/Silo-Server/silo-server/internal/blobstore" "github.com/Silo-Server/silo-server/internal/catalog" - "github.com/Silo-Server/silo-server/internal/collage" "github.com/Silo-Server/silo-server/internal/collections/templates" "github.com/Silo-Server/silo-server/internal/collectionutil" evt "github.com/Silo-Server/silo-server/internal/events" @@ -59,6 +56,12 @@ type LibraryCollectionHandler struct { JobRepo *adminjob.Repository EventsHub *evt.Hub SortPreferenceCleaner *userstore.CollectionSortPreferenceCleaner + // CollectionOwners resolves the owner's access for another profile's + // shared collection opted into the library tab. + CollectionOwners catalog.PersonalCollectionAccess + // PersonalCollages serves the collages of the personal collections opted + // into a library tab; nil when artwork storage is not configured. + PersonalCollages *catalog.PersonalCollectionCollages } var errLibraryCollectionInUse = catalog.ErrLibraryCollectionInUse @@ -175,82 +178,19 @@ func (h *LibraryCollectionHandler) storeBundledTemplatePoster( return true, storedPath, thumbhash, nil } -// ComposeCollectionCollage implements catalog.CollageGenerator. It fetches the -// source posters, composes them, and stores the result in the collection's -// collage directory with key as its revision, so every node that builds the -// same collage writes the same objects. +// ComposeCollectionCollage implements catalog.CollageGenerator for server +// collections; see collectionCollageComposer. func (h *LibraryCollectionHandler) ComposeCollectionCollage(ctx context.Context, collectionID, key string, sources []string) (string, string, error) { - if len(sources) == 0 { - return "", "", collage.ErrNotEnoughImages - } - - slog.InfoContext(ctx, "collage: generating poster", "component", "api", "collection_id", collectionID, "item_poster_count", len(sources)) - - // Resolve poster paths to fetchable URLs. The collage is stored under the - // key of all its sources, so a source that doesn't resolve or download - // fails the build rather than being left out. A later read retries it. - resolved := h.detailSvc.PresignImageURLs(ctx, sources, "poster", "small") - imageData := make([][]byte, 0, len(sources)) - for _, path := range sources { - url := resolved[path] - if url == "" { - return "", "", fmt.Errorf("collage source %q did not resolve", path) - } - data, err := h.fetchImageURL(ctx, url) - if err != nil { - // A transport error names the presigned URL; keep it out of logs. - var urlErr *neturl.Error - if errors.As(err, &urlErr) { - err = urlErr.Err - } - return "", "", fmt.Errorf("fetching collage source %q: %w", path, err) - } - imageData = append(imageData, data) - } - - // Compose the collage. - composited, err := collage.ComposePoster(imageData) - if err != nil { - return "", "", err - } - - // Process through the standard image pipeline (generates WebP variants + thumbhash). - s3Path, thumbhash, err := putCollectionImageVariants(ctx, h.ArtworkStore, collectionCollageDir(adminCollectionImagePrefix, collectionID), key, collectionPosterWidths, composited) - if err != nil { - return "", "", fmt.Errorf("processing collage image: %w", err) - } - if want := h.CollectionCollagePath(collectionID, key); s3Path != want { - return "", "", fmt.Errorf("collage stored at %q, want %q", s3Path, want) - } - - slog.InfoContext(ctx, "collage: poster generated successfully", "component", "api", "collection_id", collectionID, "s3_path", s3Path) - return s3Path, thumbhash, nil + return h.collageComposer().ComposeCollectionCollage(ctx, collectionID, key, sources) } // CollectionCollagePath implements catalog.CollageGenerator. func (h *LibraryCollectionHandler) CollectionCollagePath(collectionID, key string) string { - return artworkkey.Original(collectionCollageDir(adminCollectionImagePrefix, collectionID), key, ".webp") + return h.collageComposer().CollectionCollagePath(collectionID, key) } -// fetchImageURL downloads an image from a resolved URL. -func (h *LibraryCollectionHandler) fetchImageURL(ctx context.Context, imageURL string) ([]byte, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil) - if err != nil { - return nil, err - } - client := h.httpClient - if client == nil { - client = http.DefaultClient - } - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("image fetch returned status %d", resp.StatusCode) - } - return io.ReadAll(io.LimitReader(resp.Body, collectionImageMaxBytes)) +func (h *LibraryCollectionHandler) collageComposer() collectionCollageComposer { + return collectionCollageComposer{prefix: adminCollectionImagePrefix, store: h.ArtworkStore, posters: h.detailSvc, httpClient: h.httpClient} } type libraryCollectionResponse struct { @@ -284,6 +224,9 @@ type libraryCollectionResponse struct { ItemCount int `json:"item_count"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` + // PosterIsCollage reports that PosterURL is the viewer's collage of the + // collection's members (withViewerPosters). Only /api/v2 carries it. + PosterIsCollage bool `json:"-"` } type libraryCollectionGroupResponse struct { @@ -780,6 +723,23 @@ type libraryTabCollection struct { ItemCount int `json:"item_count"` Featured bool `json:"featured,omitempty"` CreatorProfileID *string `json:"creator_profile_id,omitempty"` + // PosterIsCollage reports that PosterURL is the viewer's collage. The + // frozen /api/v1 shape does not carry it. + PosterIsCollage bool `json:"-"` +} + +// libraryTabCardOf is the card of a server collection whose poster is already +// the viewer's (withViewerPosters). +func (h *LibraryCollectionHandler) libraryTabCardOf(ctx context.Context, c *models.LibraryCollection) libraryTabCollection { + return libraryTabCollection{ + ID: c.ID, + Title: c.Title, + PosterURL: h.presignGPURLCtx(ctx, c.PosterURL), + PosterThumbhash: c.PosterThumbhash, + ItemCount: c.ItemCount, + Featured: c.Featured, + PosterIsCollage: c.PosterAutoGenerated && c.PosterURL != "", + } } type libraryTabGroup struct { @@ -803,10 +763,9 @@ type libraryTabResponse struct { Ungrouped *libraryTabUngrouped `json:"ungrouped,omitempty"` } -// HandleListLibraryUserCollections returns the viewer's own personal -// collections that they've opted into their library Collections tab and whose -// library scope matches the requested library. Personal collections are -// private to their owner; this endpoint never reveals other users' rows. +// HandleListLibraryUserCollections serves LibraryUserCollections: the +// personal collections on the library's Collections tab that the viewer can +// see, never another login's. func (h *LibraryCollectionHandler) HandleListLibraryUserCollections(w http.ResponseWriter, r *http.Request) { libraryID, ok := parsePathLibraryID(w, r) if !ok { @@ -1547,7 +1506,7 @@ func (h *LibraryCollectionHandler) applyTemplateBundle( } entry.CollectionID = collection.ID rememberTemplateBundleExistingCollection(remainingByLibrarySlug, library.ID, collection) - if templateBundleTemplateCanInitialSync(tmpl) { + if !tmpl.NeedsSetup() { pendingSyncs = append(pendingSyncs, pendingTemplateBundleSync{ CollectionID: collection.ID, SyncSchedule: collection.SyncSchedule, @@ -1671,13 +1630,6 @@ func shouldQueueTemplateBundleSyncs(bundle templates.Bundle, pendingCount int) b return bundle.ID == "all_defaults" || pendingCount > templateBundleInlineSyncLimit } -func templateBundleTemplateCanInitialSync(tmpl templates.Template) bool { - if tmpl.Source == templates.SourceTMDBCollection { - return tmpl.TMDBCollection != nil && tmpl.TMDBCollection.CollectionID > 0 - } - return true -} - func templateBundleCreatedEntries(pending []pendingTemplateBundleSync) []templateBundleApplyEntry { entries := make([]templateBundleApplyEntry, 0, len(pending)) for _, item := range pending { @@ -2841,6 +2793,7 @@ func (h *LibraryCollectionHandler) libraryCollectionResponseOf(ctx context.Conte PosterURL: h.presignGPURLCtx(ctx, collection.PosterURL), BackdropURL: h.presignGPURLCtx(ctx, collection.BackdropURL), PosterThumbhash: collection.PosterThumbhash, + PosterIsCollage: collection.PosterAutoGenerated && collection.PosterURL != "", BackdropThumbhash: collection.BackdropThumbhash, SourceURL: collection.SourceURL, QueryDefinition: defaultJSON(collection.QueryDefinition), diff --git a/internal/api/handlers/library_views_service.go b/internal/api/handlers/library_views_service.go index ca3149f3ef..47d22eb9d8 100644 --- a/internal/api/handlers/library_views_service.go +++ b/internal/api/handlers/library_views_service.go @@ -6,6 +6,7 @@ import ( "log/slog" "net/http" "slices" + "strings" apimw "github.com/Silo-Server/silo-server/internal/api/middleware" "github.com/Silo-Server/silo-server/internal/catalog" @@ -181,7 +182,7 @@ func (h *SectionHandler) LibrarySectionItems(ctx context.Context, libraryID int, } withItems, fetchErr := h.fetcher.FetchOne(ctx, s, &libraryID, nil, userID, profileID, accessFilter) if fetchErr != nil { - slog.ErrorContext(ctx, "fetching section items", "component", "api", "section_id", s.ID, "type", s.SectionType, "error", fetchErr) + sections.LogFetchError(ctx, "api", s, fetchErr) withItems = sections.SectionWithItems{ ResolvedSection: s, Items: []*models.MediaItem{}, @@ -216,9 +217,9 @@ func (h *LibraryCollectionHandler) requireViewableLibrary(ctx context.Context, l return requireViewableLibrary(ctx, h.FolderRepo, libraryID) } -// LibraryUserCollections answers the viewer's own collections opted into -// the library's Collections tab. Personal collections are private to their -// owner; this never reveals other users' rows. +// LibraryUserCollections answers the personal collections opted into the +// library's Collections tab that the viewer can see: its own plus other +// profiles' shared collections on the same login, never another login's. func (h *LibraryCollectionHandler) LibraryUserCollections(ctx context.Context, libraryID, userID int, profileID string) ([]usercollections.ServerVisibleCollection, error) { if err := h.requireViewableLibrary(ctx, libraryID); err != nil { return nil, err @@ -233,7 +234,7 @@ func (h *LibraryCollectionHandler) LibraryUserCollections(ctx context.Context, l if collections == nil { collections = []usercollections.ServerVisibleCollection{} } - h.withVisibleItemCounts(ctx, userID, collections) + collections = h.withVisibleItemCounts(ctx, userID, profileID, collections) for i := range collections { collections[i].PosterURL = h.presignGPURLCtx(ctx, collections[i].PosterPath) } @@ -241,18 +242,35 @@ func (h *LibraryCollectionHandler) LibraryUserCollections(ctx context.Context, l } // withVisibleItemCounts sets each personal collection's item_count to the -// members the acting profile can see, as the personal collection routes do. -func (h *LibraryCollectionHandler) withVisibleItemCounts(ctx context.Context, userID int, collections []usercollections.ServerVisibleCollection) { - sources := make([]catalog.PersonalCollectionDefinition, 0, len(collections)) +// members the acting profile can see, as the personal collection routes do: +// for another profile's collection, only those its owner can access too. On +// /api/v2 a collection without an uploaded or imported poster takes its +// collage for that profile, once built. A collection whose owner cannot be +// resolved is left out of the result. +func (h *LibraryCollectionHandler) withVisibleItemCounts(ctx context.Context, userID int, profileID string, collections []usercollections.ServerVisibleCollection) []usercollections.ServerVisibleCollection { + sources := make([]ownedCollectionDefinition, 0, len(collections)) for _, c := range collections { - sources = append(sources, catalog.PersonalCollectionDefinition{ID: c.ID, CollectionType: c.CollectionType, QueryDefinition: c.QueryDefinition, DisplayQueryDefinition: c.DisplayQueryDefinition}) + sources = append(sources, ownedCollectionDefinition{ + PersonalCollectionDefinition: catalog.PersonalCollectionDefinition{ID: c.ID, CollectionType: c.CollectionType, QueryDefinition: c.QueryDefinition, DisplayQueryDefinition: c.DisplayQueryDefinition}, + CreatorProfileID: c.CreatorProfileID, + WantsCollage: strings.TrimSpace(c.PosterPath) == "", + }) } - counts := visiblePersonalCollectionCounts(ctx, h.Executor, userID, sources, AccessFilterFromContext(ctx, "")) - for i := range collections { - if n, ok := counts[collections[i].ID]; ok { - collections[i].ItemCount = n + reads := ownerScopedCollectionReads(ctx, h.Executor, h.CollectionOwners, collagesForRead(ctx, h.PersonalCollages), userID, profileID, sources, AccessFilterFromContext(ctx, "")) + out := collections[:0] + for _, c := range collections { + if reads.unavailable[c.ID] { + continue + } + if n, ok := reads.counts[c.ID]; ok { + c.ItemCount = n } + if collage, ok := reads.posters[c.ID]; ok { + c.PosterPath, c.PosterThumbhash, c.PosterIsCollage = collage.Path, collage.Thumbhash, true + } + out = append(out, c) } + return out } // LibraryCollectionsTab answers the library's Collections tab: every @@ -295,8 +313,7 @@ func (h *LibraryCollectionHandler) LibraryCollectionsTab(ctx context.Context, li if loadErr != nil { return LibraryCollectionTabView{}, apiError(http.StatusInternalServerError, "internal_error", "Failed to load user collections") } - h.withVisibleItemCounts(ctx, userID, loadedUserCollections) - userCollections = loadedUserCollections + userCollections = h.withVisibleItemCounts(ctx, userID, profileID, loadedUserCollections) userCollectionsLoaded = true } sorted := applyUserCollectionSort(userCollections, g.DefaultSortMode) @@ -310,20 +327,14 @@ func (h *LibraryCollectionHandler) LibraryCollectionsTab(ctx context.Context, li PosterThumbhash: sorted[i].PosterThumbhash, ItemCount: sorted[i].ItemCount, CreatorProfileID: &creatorProfileID, + PosterIsCollage: sorted[i].PosterIsCollage, }) } default: collections := adminCollectionsByGroup[g.ID] collections = applyCollectionSort(collections, g.DefaultSortMode) for _, c := range collections { - colls = append(colls, libraryTabCollection{ - ID: c.ID, - Title: c.Title, - PosterURL: h.presignGPURLCtx(ctx, c.PosterURL), - PosterThumbhash: c.PosterThumbhash, - ItemCount: c.ItemCount, - Featured: c.Featured, - }) + colls = append(colls, h.libraryTabCardOf(ctx, c)) } } if len(colls) == 0 { @@ -343,14 +354,7 @@ func (h *LibraryCollectionHandler) LibraryCollectionsTab(ctx context.Context, li if len(ungrouped) > 0 { uColls := make([]libraryTabCollection, 0, len(ungrouped)) for _, c := range ungrouped { - uColls = append(uColls, libraryTabCollection{ - ID: c.ID, - Title: c.Title, - PosterURL: h.presignGPURLCtx(ctx, c.PosterURL), - PosterThumbhash: c.PosterThumbhash, - ItemCount: c.ItemCount, - Featured: c.Featured, - }) + uColls = append(uColls, h.libraryTabCardOf(ctx, c)) } sortOrder, err := h.GroupRepo.GetUngroupedSortOrder(ctx, libraryID) if err != nil { diff --git a/internal/api/handlers/section_source_policy_test.go b/internal/api/handlers/section_source_policy_test.go index bb23aa679a..5c08befa64 100644 --- a/internal/api/handlers/section_source_policy_test.go +++ b/internal/api/handlers/section_source_policy_test.go @@ -8,6 +8,8 @@ import ( "testing" "time" + apimw "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/auth" sectionspkg "github.com/Silo-Server/silo-server/internal/sections" "github.com/Silo-Server/silo-server/internal/userstore" ) @@ -435,3 +437,52 @@ func TestTraktCollectionImportsAreDisabled(t *testing.T) { t.Fatalf("profile Trakt import error = %#v", err) } } + +// A profile that is not an admin may always add rule rows, and keeps and +// changes the Editor's picks rows it already has, but can't add a new one. +func TestProfileRuleRowsAndKeptEditorsPicks(t *testing.T) { + query := SectionOverridesQuery{UserID: 1, ProfileID: "p1", Scope: "home"} + picks := SectionOverrideWrite{ID: "picks", IsUserAdded: true, UserSectionType: "admin_curated_list", UserConfig: json.RawMessage(`{"item_ids":["a","b"]}`)} + rules := SectionOverrideWrite{ID: "rules", IsUserAdded: true, UserSectionType: "custom_filter", UserConfig: json.RawMessage(`{"filter":{}}`)} + stored := []userstore.SectionOverride{{ID: "picks", IsUserAdded: true, UserSectionType: "admin_curated_list", UserConfig: `{"item_ids":["a"]}`}} + profile := apimw.SetClaims(t.Context(), &auth.Claims{Role: "user", UserID: 1}) + admin := apimw.SetClaims(t.Context(), &auth.Claims{Role: "admin", UserID: 1}) + + cases := []struct { + name string + ctx context.Context + stored []userstore.SectionOverride + writes []SectionOverrideWrite + refuse bool + }{ + {"rule row with nothing saved", profile, nil, []SectionOverrideWrite{rules}, false}, + {"kept and changed Editor's picks beside a new rule row", profile, stored, []SectionOverrideWrite{picks, rules}, false}, + {"new Editor's picks", profile, nil, []SectionOverrideWrite{picks}, true}, + {"Editor's picks over another row's ID", profile, []userstore.SectionOverride{{ID: "picks", IsUserAdded: true, UserSectionType: "hidden_gems"}}, []SectionOverrideWrite{picks}, true}, + {"Editor's picks over a server row's override", profile, []userstore.SectionOverride{{ID: "picks", SectionID: "admin-1", SectionType: "admin_curated_list"}}, []SectionOverrideWrite{picks}, true}, + {"admin adds Editor's picks", admin, nil, []SectionOverrideWrite{picks}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + store := &sourcePolicyStore{overrides: tc.stored} + h := &SectionHandler{StoreProvider: sourcePolicyProvider{store: store}} + err := h.SaveProfileOverrides(tc.ctx, query, tc.writes) + if !tc.refuse { + if err != nil { + t.Fatalf("SaveProfileOverrides: %v", err) + } + if len(store.overrides) != len(tc.writes) { + t.Fatalf("saved %+v", store.overrides) + } + return + } + apiErr, ok := errors.AsType[*APIError](err) + if !ok || apiErr.Status != 403 || apiErr.Code != "custom_disabled" { + t.Fatalf("error = %#v, want 403 custom_disabled", err) + } + if len(store.overrides) != len(tc.stored) { + t.Fatalf("refused save changed the store: %+v", store.overrides) + } + }) + } +} diff --git a/internal/api/handlers/sections.go b/internal/api/handlers/sections.go index 1953a729af..81551cc5b2 100644 --- a/internal/api/handlers/sections.go +++ b/internal/api/handlers/sections.go @@ -40,7 +40,6 @@ type SectionHandler struct { UserRepo *auth.UserRepository AccessGroups access.GroupPolicyProvider // optional; resolves inherited library access when no scope is in context DetailSvc *catalog.DetailService - Settings catalog.SettingsStore CollectionRepo *catalog.LibraryCollectionRepository SortPreferenceCleaner *userstore.CollectionSortPreferenceCleaner EbookProgress EbookReaderProgressLister @@ -106,6 +105,9 @@ type createSectionRequest struct { ItemLimit int `json:"item_limit"` Config json.RawMessage `json:"config"` Enabled bool `json:"enabled"` + // ValidateRecipe runs the section type's own config check. Only /api/v2 + // sets it; the frozen /api/v1 routes keep accepting what they always did. + ValidateRecipe bool `json:"-"` } type updateSectionRequest struct { @@ -116,6 +118,8 @@ type updateSectionRequest struct { ItemLimit *int `json:"item_limit"` Config json.RawMessage `json:"config,omitempty"` Enabled *bool `json:"enabled"` + // ValidateRecipe: see createSectionRequest. + ValidateRecipe bool `json:"-"` } type reorderSectionsRequest struct { @@ -204,6 +208,20 @@ func validateSectionConfig(sectionType sections.SectionType, config json.RawMess return "", true } +// validateRecipeConfig runs the section type's own recipe check, which bulk +// create and profile saves also run; validateSectionConfig only checks the +// keys every section type shares. +func validateRecipeConfig(sectionType sections.SectionType, config json.RawMessage) (string, bool) { + rec, ok := recipes.Get(string(sectionType)) + if !ok { + return "", true + } + if err := rec.Validate(config); err != nil { + return err.Error(), false + } + return "", true +} + func validateSectionScope(scope string, libraryID *int) (string, bool) { switch scope { case "", "home": @@ -760,12 +778,28 @@ func (h *SectionHandler) HandleSaveProfileOverrides(w http.ResponseWriter, r *ht w.WriteHeader(http.StatusNoContent) } +// adminOnlyRecipeError refuses a new section of an admin-only recipe +// (Editor's picks) from a profile that is not an admin. The code and message +// are the frozen v1 ones from when a server setting decided this. +func adminOnlyRecipeError() *APIError { + return apiError(http.StatusForbidden, "custom_disabled", "this server does not allow profiles to build custom sections") +} + +// userAddedRecipe is the recipe a stored user-added override runs, read the +// way the resolver reads it. +func userAddedRecipe(o userstore.SectionOverride) string { + if o.UserSectionType != "" { + return o.UserSectionType + } + return o.SectionType +} + // SaveProfileOverrides replaces the profile's override set for one page: -// the recipe gate on user-added sections (registered recipe, admin-only -// recipes need the admin role or the allow-custom setting, config validated -// by the recipe), then the store write. v1 PUT /profile/sections and v2 -// replaceProfileSectionOverrides both call it; a failure is an *APIError -// carrying the v1 status, code and message. +// the recipe gate on user-added sections (registered recipe, config +// validated by the recipe, and a profile that is not an admin may keep but +// not add a section of an admin-only recipe), then the store write. v1 PUT +// /profile/sections and v2 replaceProfileSectionOverrides both call it; a +// failure is an *APIError carrying the v1 status, code and message. func (h *SectionHandler) SaveProfileOverrides(ctx context.Context, q SectionOverridesQuery, writes []SectionOverrideWrite) error { if err := h.requireOverrideLibrary(ctx, q.Scope, q.LibraryID); err != nil { return err @@ -778,11 +812,9 @@ func (h *SectionHandler) SaveProfileOverrides(ctx context.Context, q SectionOver // own routes, the owning account's for an administrator's write. func (h *SectionHandler) saveProfileOverrides(ctx context.Context, q SectionOverridesQuery, writes []SectionOverrideWrite, isAdmin bool) error { // Gate: validate user-added overrides before touching the store. - allowCustom := false - if h.Settings != nil { - v, _ := h.Settings.Get(ctx, SectionsAllowProfileCustomSettingKey) - allowCustom = v == "true" - } + // ID -> recipe of the admin-only sections a non-admin sends; each must + // already be saved on this page with the same recipe. + keptAdminOnly := make(map[string]string) type trendingCandidate struct { id string @@ -866,8 +898,11 @@ func (h *SectionHandler) saveProfileOverrides(ctx context.Context, q SectionOver if !ok { return apiError(http.StatusBadRequest, "unknown_recipe", "section_type not registered: "+recipeType) } - if rec.Definition().AdminOnly && !isAdmin && !allowCustom { - return apiError(http.StatusForbidden, "custom_disabled", "this server does not allow profiles to build custom sections") + if rec.Definition().AdminOnly && !isAdmin { + if o.ID == "" { + return adminOnlyRecipeError() + } + keptAdminOnly[o.ID] = recipeType } // Validate whichever config the resolver will actually use. cfg := o.UserConfig @@ -907,6 +942,12 @@ func (h *SectionHandler) saveProfileOverrides(ctx context.Context, q SectionOver if err := h.rejectLegacyTraktReactivationByRemoval(ctx, existing, retainedSectionIDs); err != nil { return err } + for id, recipeType := range keptAdminOnly { + old, ok := existingByID[id] + if !ok || old.SectionID != "" || userAddedRecipe(old) != recipeType { + return adminOnlyRecipeError() + } + } for id, write := range sourceWrites { old, ok := existingByID[id] if !ok && write.kind != "" && (write.sectionID == "" || write.active) { diff --git a/internal/api/handlers/sections_preview_test.go b/internal/api/handlers/sections_preview_test.go index 0fcae5d364..a28e341ea0 100644 --- a/internal/api/handlers/sections_preview_test.go +++ b/internal/api/handlers/sections_preview_test.go @@ -4,8 +4,10 @@ import ( "bytes" "context" "encoding/json" + "maps" "net/http" "net/http/httptest" + "slices" "testing" "github.com/Silo-Server/silo-server/internal/catalog" @@ -63,3 +65,104 @@ func TestHandlePreviewValidConfig(t *testing.T) { t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) } } + +// The v2 admin preview presigns each item's poster exactly as a live row does, +// so an admin peek shows the same image the row will show once saved. +func TestAdminSectionPreviewPresignsPostersLikeLiveRows(t *testing.T) { + resolver := &countingSectionImageResolver{} + detail := &catalog.DetailService{} + detail.SetImageResolver(resolver) + stored := &models.MediaItem{ContentID: "stored", Type: "movie", PosterPath: "/poster/original.jpg", BackdropPath: "/backdrop/original.jpg", LogoPath: "/logo/original.png"} + h := &SectionHandler{DetailSvc: detail, previewFetcher: &stubPreviewFetcher{ + items: []*models.MediaItem{stored, {ContentID: "remote", PosterPath: "https://image.example/remote.jpg"}, {ContentID: "bare"}, nil}, + total: 4, + }} + + result, err := h.PreviewAdminSection(t.Context(), AdminSectionPreviewRequest{SectionType: string(sections.SectionRecentlyAdded), Config: json.RawMessage(`{}`)}) + if err != nil { + t.Fatal(err) + } + if len(result.Items) != 4 || result.TotalCount != 4 { + t.Fatalf("preview items changed: %+v", result) + } + want := map[string]string{"stored": "resolved:/poster/w500.jpg", "remote": "https://image.example/remote.jpg"} + if !maps.Equal(result.PosterURLs, want) { + t.Fatalf("poster URLs = %v, want %v", result.PosterURLs, want) + } + if resolver.batchCalls != 1 || resolver.singleCalls != 0 || resolver.variant != "featured" { + t.Fatalf("signing calls batch=%d single=%d variant=%q", resolver.batchCalls, resolver.singleCalls, resolver.variant) + } + if !slices.Equal(resolver.paths, []string{"/poster/w500.jpg"}) { + t.Fatalf("signed paths = %v, want only the poster", resolver.paths) + } + + live := h.buildSectionsResponse(httptest.NewRequest(http.MethodGet, "/sections", nil), []sections.SectionWithItems{{ + ResolvedSection: sections.ResolvedSection{ID: "row", SectionType: sections.SectionRecentlyAdded}, + Items: []*models.MediaItem{stored}, + }}, nil) + if got := live.Sections[0].Items[0].PosterURL; got != result.PosterURLs["stored"] { + t.Fatalf("live row poster %q, preview poster %q", got, result.PosterURLs["stored"]) + } +} + +// Without an image signer the preview still answers, just without posters. +func TestAdminSectionPreviewWithoutSignerOmitsPosters(t *testing.T) { + h := &SectionHandler{previewFetcher: &stubPreviewFetcher{items: []*models.MediaItem{{ContentID: "a", PosterPath: "/poster/original.jpg"}}, total: 1}} + result, err := h.PreviewAdminSection(t.Context(), AdminSectionPreviewRequest{SectionType: string(sections.SectionRecentlyAdded), Config: json.RawMessage(`{}`)}) + if err != nil { + t.Fatal(err) + } + if len(result.Items) != 1 || len(result.PosterURLs) != 0 { + t.Fatalf("preview without signer = %+v", result) + } +} + +// The frozen /api/v1 preview keeps its body: no signing, no new fields. +func TestHandlePreviewV1BodyUnchanged(t *testing.T) { + resolver := &countingSectionImageResolver{} + detail := &catalog.DetailService{} + detail.SetImageResolver(resolver) + h := &SectionHandler{DetailSvc: detail, previewFetcher: &stubPreviewFetcher{items: []*models.MediaItem{{ContentID: "a", PosterPath: "/poster/original.jpg"}}, total: 1}} + body, _ := json.Marshal(map[string]any{"section_type": string(sections.SectionRecentlyAdded), "config": map[string]any{}}) + rec := httptest.NewRecorder() + h.HandlePreview(rec, httptest.NewRequest(http.MethodPost, "/api/admin/sections/preview", bytes.NewReader(body))) + if rec.Code != http.StatusOK { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } + var got map[string]json.RawMessage + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if len(got) != 2 || got["items"] == nil || got["total_count"] == nil || resolver.batchCalls+resolver.singleCalls != 0 { + t.Fatalf("v1 preview body changed: %s (signing calls %d)", rec.Body.String(), resolver.batchCalls+resolver.singleCalls) + } +} + +// Items that share a poster ask the signer for it once, in item order, the +// way a live row collects its paths. +func TestAdminSectionPreviewSignsEachPosterOnce(t *testing.T) { + resolver := &countingSectionImageResolver{} + detail := &catalog.DetailService{} + detail.SetImageResolver(resolver) + h := &SectionHandler{DetailSvc: detail, previewFetcher: &stubPreviewFetcher{ + items: []*models.MediaItem{ + {ContentID: "b", PosterPath: "/b/original.jpg"}, + {ContentID: "a", PosterPath: "/a/original.jpg"}, + {ContentID: "a-again", PosterPath: "/a/original.jpg"}, + {ContentID: "none"}, + }, + total: 4, + }} + + result, err := h.PreviewAdminSection(t.Context(), AdminSectionPreviewRequest{SectionType: string(sections.SectionRecentlyAdded), Config: json.RawMessage(`{}`)}) + if err != nil { + t.Fatal(err) + } + if want := []string{"/b/w500.jpg", "/a/w500.jpg"}; !slices.Equal(resolver.paths, want) { + t.Fatalf("signed paths = %v, want %v", resolver.paths, want) + } + want := map[string]string{"b": "resolved:/b/w500.jpg", "a": "resolved:/a/w500.jpg", "a-again": "resolved:/a/w500.jpg"} + if !maps.Equal(result.PosterURLs, want) { + t.Fatalf("poster URLs = %v, want %v", result.PosterURLs, want) + } +} diff --git a/internal/api/handlers/sections_settings.go b/internal/api/handlers/sections_settings.go index 66894275cd..1980022470 100644 --- a/internal/api/handlers/sections_settings.go +++ b/internal/api/handlers/sections_settings.go @@ -1,92 +1,39 @@ package handlers import ( - "context" "encoding/json" "net/http" - - "github.com/Silo-Server/silo-server/internal/catalog" ) -// SectionsAllowProfileCustomSettingKey is the server_settings key controlling -// whether non-admin profiles may add user-added sections of admin-only recipes. -const SectionsAllowProfileCustomSettingKey = "sections.allow_profile_custom_sections" - -// SectionSettingsHandler exposes GET/PUT for the sections-related server setting. -type SectionSettingsHandler struct { - Settings catalog.SettingsStore -} +// SectionSettingsHandler serves the frozen /api/v1 routes of the retired +// "let profiles add rule rows" setting. Profiles may always add rule rows +// now, so every read reports true and a write changes nothing. A stored +// sections.allow_profile_custom_sections row is left in place and ignored. +type SectionSettingsHandler struct{} type sectionsSettingResponse struct { AllowProfileCustomSections bool `json:"allow_profile_custom_sections"` } -// HandleGet handles GET /api/admin/settings/sections. +var alwaysAllowed = sectionsSettingResponse{AllowProfileCustomSections: true} + +// HandleGet handles GET /api/v1/admin/settings/sections. func (h *SectionSettingsHandler) HandleGet(w http.ResponseWriter, r *http.Request) { - var resp sectionsSettingResponse - if h.Settings != nil { - v, _ := h.Settings.Get(r.Context(), SectionsAllowProfileCustomSettingKey) - resp.AllowProfileCustomSections = v == "true" - } - writeJSON(w, http.StatusOK, resp) + writeJSON(w, http.StatusOK, alwaysAllowed) } -// HandleGetProfileFlag exposes the allow_profile_custom_sections setting to any -// authenticated profile (no admin required). Read-only. +// HandleGetProfileFlag handles GET /api/v1/profile/sections/flags. func (h *SectionSettingsHandler) HandleGetProfileFlag(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, sectionsSettingResponse{AllowProfileCustomSections: h.AllowProfileCustomSections(r.Context())}) -} - -// AllowProfileCustomSections reads the allow_profile_custom_sections setting; -// false when no settings store is wired. v1 GET /profile/sections/flags and -// v2 getProfileSectionFlags both call it. -func (h *SectionSettingsHandler) AllowProfileCustomSections(ctx context.Context) bool { - if h.Settings == nil { - return false - } - v, _ := h.Settings.Get(ctx, SectionsAllowProfileCustomSettingKey) - return v == "true" + writeJSON(w, http.StatusOK, alwaysAllowed) } -// HandlePut handles PUT /api/admin/settings/sections. +// HandlePut handles PUT /api/v1/admin/settings/sections. The body is still +// checked as JSON, then ignored: the answer is the value in effect. func (h *SectionSettingsHandler) HandlePut(w http.ResponseWriter, r *http.Request) { var req sectionsSettingResponse if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "bad_json", err.Error()) return } - if h.Settings != nil { - value := "false" - if req.AllowProfileCustomSections { - value = "true" - } - if err := h.Settings.Set(r.Context(), SectionsAllowProfileCustomSettingKey, value); err != nil { - writeError(w, http.StatusInternalServerError, "save_failed", err.Error()) - return - } - } - writeJSON(w, http.StatusOK, req) -} - -// UpdateAdminSectionSettings evaluates a guard against the stored flag while -// holding the existing settings transaction. The profile read remains unchanged. -func (h *SectionSettingsHandler) UpdateAdminSectionSettings(ctx context.Context, enabled bool, guard func(bool) error) error { - updater, ok := h.Settings.(serverSettingsAtomicUpdater) - if !ok { - return &APIError{Status: http.StatusServiceUnavailable, Code: "unavailable", Message: "Atomic settings store not configured"} - } - return updater.UpdateAtomic(ctx, func(current map[string]string) (map[string]string, error) { - before := current[SectionsAllowProfileCustomSettingKey] == "true" - if err := guard(before); err != nil { - return nil, err - } - if before == enabled { - return nil, nil - } - value := "false" - if enabled { - value = "true" - } - return map[string]string{SectionsAllowProfileCustomSettingKey: value}, nil - }) + writeJSON(w, http.StatusOK, alwaysAllowed) } diff --git a/internal/api/handlers/sections_settings_default_title_db_test.go b/internal/api/handlers/sections_settings_default_title_db_test.go new file mode 100644 index 0000000000..3280513534 --- /dev/null +++ b/internal/api/handlers/sections_settings_default_title_db_test.go @@ -0,0 +1,101 @@ +package handlers + +import ( + "context" + "encoding/json" + "strconv" + "testing" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/sections" + "github.com/Silo-Server/silo-server/internal/userstore" + "github.com/Silo-Server/silo-server/internal/userstore/pgstore" +) + +// TestProfileSectionSettingsKeepAdminTitleDB: the settings read over the +// real section repository and profile store returns the profile's rename as +// the title and the admin row's own title beside it, and follows a later +// admin rename for the default while the profile's rename stays. +func TestProfileSectionSettingsKeepAdminTitleDB(t *testing.T) { + f := newPagingIntegrationFixture(t) + ctx := t.Context() + provider := pgstore.NewPostgresProvider(f.pool) + store, err := provider.ForUser(ctx, f.account) + if err != nil { + t.Fatal(err) + } + if err := store.CreateProfile(ctx, userstore.Profile{ID: "p1", Name: "p1"}); err != nil { + t.Fatal(err) + } + + repo := sections.NewRepository(f.pool) + library := f.library + create := func(title string, position int) *sections.PageSection { + t.Helper() + s, err := repo.Create(ctx, §ions.PageSection{ + Scope: "library", LibraryID: &library, Position: position, SectionType: sections.SectionRecentlyAdded, + Title: title, ItemLimit: 20, Config: json.RawMessage(`{}`), Enabled: true, + }) + if err != nil { + t.Fatal(err) + } + return s + } + renamed := create("Recently Added", 0) + untouched := create("Favorites", 1) + t.Cleanup(func() { + _, _ = f.pool.Exec(context.Background(), `DELETE FROM page_sections WHERE id = ANY($1)`, []string{renamed.ID, untouched.ID}) + }) + + libraryKey := strconv.Itoa(library) + position := 2 + if err := store.SaveSectionOverrides(ctx, "p1", "library", libraryKey, []userstore.SectionOverride{ + {ID: "o-renamed", SectionID: renamed.ID, Title: "Weekend picks"}, + {ID: "o-gems", IsUserAdded: true, UserSectionType: string(sections.SectionHiddenGems), UserTitle: "Hidden gems", Position: &position}, + }); err != nil { + t.Fatal(err) + } + + h := NewSectionHandler(repo, nil) + h.StoreProvider = provider + titles := func() map[string][2]string { + t.Helper() + resolved, err := h.ResolveProfileSectionSettings(ctx, f.account, "p1", "library", &library, catalog.AccessFilter{}) + if err != nil { + t.Fatal(err) + } + out := map[string][2]string{} + for _, s := range resolved { + out[s.ID] = [2]string{s.Title, s.DefaultTitle} + } + return out + } + assert := func(want map[string][2]string) { + t.Helper() + got := titles() + if len(got) != len(want) { + t.Fatalf("settings = %q, want %q", got, want) + } + for id, w := range want { + if got[id] != w { + t.Errorf("section %s (title, default title) = %q, want %q", id, got[id], w) + } + } + } + + assert(map[string][2]string{ + renamed.ID: {"Weekend picks", "Recently Added"}, + untouched.ID: {"Favorites", "Favorites"}, + "o-gems": {"Hidden gems", ""}, + }) + + renamed.Title = "New this week" + if err := repo.Update(ctx, renamed); err != nil { + t.Fatal(err) + } + assert(map[string][2]string{ + renamed.ID: {"Weekend picks", "New this week"}, + untouched.ID: {"Favorites", "Favorites"}, + "o-gems": {"Hidden gems", ""}, + }) +} diff --git a/internal/api/handlers/sections_settings_test.go b/internal/api/handlers/sections_settings_test.go index c47a3d7a52..d6283e8ca3 100644 --- a/internal/api/handlers/sections_settings_test.go +++ b/internal/api/handlers/sections_settings_test.go @@ -7,32 +7,30 @@ import ( "testing" ) -func TestGetSectionsSettingDefaultsToFalse(t *testing.T) { - h := &SectionSettingsHandler{} // no Settings repo wired - req := httptest.NewRequest(http.MethodGet, "/api/admin/settings/sections", nil) - rec := httptest.NewRecorder() - - h.HandleGet(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status %d", rec.Code) - } - if !strings.Contains(rec.Body.String(), `"allow_profile_custom_sections":false`) { - t.Fatalf("default not false: %s", rec.Body.String()) +// The retired "let profiles add rule rows" setting reads true on every v1 +// route, and a v1 write is accepted without changing anything. +func TestSectionSettingsAlwaysAllowProfileRuleRows(t *testing.T) { + h := &SectionSettingsHandler{} + for name, serve := range map[string]func(http.ResponseWriter, *http.Request){ + "admin get": h.HandleGet, + "profile flag": h.HandleGetProfileFlag, + } { + rec := httptest.NewRecorder() + serve(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != `{"allow_profile_custom_sections":true}` { + t.Fatalf("%s: %d %s", name, rec.Code, rec.Body.String()) + } } -} -func TestGetProfileFlagDefaultsToFalse(t *testing.T) { - h := &SectionSettingsHandler{} // no Settings repo wired - req := httptest.NewRequest(http.MethodGet, "/api/profile/sections/flags", nil) rec := httptest.NewRecorder() - - h.HandleGetProfileFlag(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status %d", rec.Code) + h.HandlePut(rec, httptest.NewRequest(http.MethodPut, "/", strings.NewReader(`{"allow_profile_custom_sections":false}`))) + if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != `{"allow_profile_custom_sections":true}` { + t.Fatalf("put: %d %s", rec.Code, rec.Body.String()) } - if !strings.Contains(rec.Body.String(), `"allow_profile_custom_sections":false`) { - t.Fatalf("default not false: %s", rec.Body.String()) + + rec = httptest.NewRecorder() + h.HandlePut(rec, httptest.NewRequest(http.MethodPut, "/", strings.NewReader(`{`))) + if rec.Code != http.StatusBadRequest { + t.Fatalf("malformed put: %d %s", rec.Code, rec.Body.String()) } } diff --git a/internal/api/handlers/sections_settings_write_test.go b/internal/api/handlers/sections_settings_write_test.go deleted file mode 100644 index 645e40d57d..0000000000 --- a/internal/api/handlers/sections_settings_write_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package handlers - -import ( - "context" - "errors" - "testing" -) - -func TestAdminSectionSettingsAtomicGuard(t *testing.T) { - store := &fakeServerSettingsStore{values: map[string]string{SectionsAllowProfileCustomSettingKey: "false", "unrelated": "preserved"}} - h := &SectionSettingsHandler{Settings: store} - ctx := context.Background() - rejected := errors.New("stale") - err := h.UpdateAdminSectionSettings(ctx, true, func(current bool) error { - if current { - t.Fatal("wrong current flag") - } - return rejected - }) - if !errors.Is(err, rejected) || store.setManyCalls != 0 { - t.Fatal(err, store.setManyCalls) - } - err = h.UpdateAdminSectionSettings(ctx, true, func(current bool) error { return nil }) - if err != nil || store.values[SectionsAllowProfileCustomSettingKey] != "true" || store.values["unrelated"] != "preserved" { - t.Fatal(err, store.values) - } - writes := store.setManyCalls - err = h.UpdateAdminSectionSettings(ctx, true, func(current bool) error { - if !current { - t.Fatal("guard outside current snapshot") - } - return nil - }) - if err != nil || store.setManyCalls != writes { - t.Fatal("unchanged value wrote", err) - } - h.Settings = nil - if h.UpdateAdminSectionSettings(ctx, false, func(bool) error { return nil }) == nil { - t.Fatal("missing atomic store accepted") - } -} diff --git a/internal/api/handlers/sections_test.go b/internal/api/handlers/sections_test.go index 0ec06ce152..8a8eb8feb4 100644 --- a/internal/api/handlers/sections_test.go +++ b/internal/api/handlers/sections_test.go @@ -737,10 +737,9 @@ func TestApplyDiversityFilterAvoidSectionDoesNotShadowItself(t *testing.T) { } } -// TestSaveProfileOverridesRejectsAdminOnlyRecipeWhenSettingDisabled verifies that -// a non-admin profile attempting to save a user-added admin_curated_list override -// gets 403 when the allow_profile_custom_sections setting is disabled (the default). -func TestSaveProfileOverridesRejectsAdminOnlyRecipeWhenSettingDisabled(t *testing.T) { +// TestSaveProfileOverridesRejectsNewAdminOnlyRecipe verifies that a non-admin +// profile adding a user-added admin_curated_list override gets 403. +func TestSaveProfileOverridesRejectsNewAdminOnlyRecipe(t *testing.T) { // StoreProvider is nil so we test the gate in isolation. // The gate runs BEFORE the StoreProvider check, so a 403 is returned before // the nil-StoreProvider 500 path is reached. diff --git a/internal/api/handlers/server_collections.go b/internal/api/handlers/server_collections.go index 8c5393e12b..708e6bb4b6 100644 --- a/internal/api/handlers/server_collections.go +++ b/internal/api/handlers/server_collections.go @@ -97,14 +97,7 @@ func (h *LibraryCollectionHandler) ListServerCollections(ctx context.Context, ac colls := make([]libraryTabCollection, 0, len(collections)) for _, c := range collections { - colls = append(colls, libraryTabCollection{ - ID: c.ID, - Title: c.Title, - PosterURL: h.presignGPURLCtx(ctx, c.PosterURL), - PosterThumbhash: c.PosterThumbhash, - ItemCount: c.ItemCount, - Featured: c.Featured, - }) + colls = append(colls, h.libraryTabCardOf(ctx, c)) } resp.Libraries = append(resp.Libraries, ServerCollectionsLibraryView{ diff --git a/internal/api/handlers/tmdb_list_collections_test.go b/internal/api/handlers/tmdb_list_collections_test.go index 760577b269..fe69711c98 100644 --- a/internal/api/handlers/tmdb_list_collections_test.go +++ b/internal/api/handlers/tmdb_list_collections_test.go @@ -76,7 +76,7 @@ func TestPersonalTMDBListImportRejectsNonListURL(t *testing.T) { func TestPersonalTMDBListSourceURLCanBeEdited(t *testing.T) { store := &lifecycleStore{collection: userstore.Collection{ - ID: "c", CreatorProfileID: "owner", AllowedProfileIDs: []string{"owner"}, + ID: "c", CreatorProfileID: "owner", CollectionType: "tmdb", SourceURL: "https://www.themoviedb.org/list/310", SourceConfig: `{"mode":"tmdb_list","url":"https://www.themoviedb.org/list/310"}`, }} @@ -112,7 +112,7 @@ func TestPersonalTMDBListSourceURLCanBeEdited(t *testing.T) { func TestPersonalTMDBPresetSourceURLCannotBeEdited(t *testing.T) { store := &lifecycleStore{collection: userstore.Collection{ - ID: "c", CreatorProfileID: "owner", AllowedProfileIDs: []string{"owner"}, + ID: "c", CreatorProfileID: "owner", CollectionType: "tmdb", SourceConfig: `{"mode":"tmdb_preset","preset":"trending"}`, }} h := NewCollectionHandler(lifecycleProvider{store: store}) diff --git a/internal/api/handlers/user_collection_imports.go b/internal/api/handlers/user_collection_imports.go index ef8ea12dab..12147a6601 100644 --- a/internal/api/handlers/user_collection_imports.go +++ b/internal/api/handlers/user_collection_imports.go @@ -109,7 +109,11 @@ func (h *UserCollectionImportHandler) HandleImportMDBList(w http.ResponseWriter, writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") return } - resp, err := h.ImportMDBList(r.Context(), apimw.GetUserID(r.Context()), apimw.GetProfileID(r.Context()), req) + userID := apimw.GetUserID(r.Context()) + resp, err := h.ImportMDBList(r.Context(), userID, apimw.GetProfileID(r.Context()), req) + if err == nil { + err = v1CollectionAudience(r.Context(), h.storeProvider, userID, &resp.Collection) + } if err != nil { writeAPIError(w, err) return @@ -123,7 +127,11 @@ func (h *UserCollectionImportHandler) HandleImportTMDB(w http.ResponseWriter, r writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") return } - resp, err := h.ImportTMDB(r.Context(), apimw.GetUserID(r.Context()), apimw.GetProfileID(r.Context()), req) + userID := apimw.GetUserID(r.Context()) + resp, err := h.ImportTMDB(r.Context(), userID, apimw.GetProfileID(r.Context()), req) + if err == nil { + err = v1CollectionAudience(r.Context(), h.storeProvider, userID, &resp.Collection) + } if err != nil { writeAPIError(w, err) return @@ -137,7 +145,11 @@ func (h *UserCollectionImportHandler) HandleImportTrakt(w http.ResponseWriter, r writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") return } - resp, err := h.ImportTrakt(r.Context(), apimw.GetUserID(r.Context()), apimw.GetProfileID(r.Context()), req) + userID := apimw.GetUserID(r.Context()) + resp, err := h.ImportTrakt(r.Context(), userID, apimw.GetProfileID(r.Context()), req) + if err == nil { + err = v1CollectionAudience(r.Context(), h.storeProvider, userID, &resp.Collection) + } if err != nil { writeAPIError(w, err) return @@ -308,7 +320,7 @@ func (h *UserCollectionImportHandler) createImportedCollection( return none, apiError(http.StatusInternalServerError, "internal_error", "Failed to process template poster") } - syncResult, updated, syncErr := h.sync.RunSync(ctx, store, collection) + syncResult, updated, syncErr := h.sync.RunSync(ctx, userID, store, collection) if syncErr != nil { // Persist failure state inline so the UI shows the error and the user // can retry; the row is intentionally kept around for that retry path. @@ -318,10 +330,19 @@ func (h *UserCollectionImportHandler) createImportedCollection( Message: syncErr.Error(), LastSyncAt: time.Now().UTC(), NextSyncAt: usercollections.InitialNextSyncAt(schedule), + + // The sync started on the schedule and next run the collection was + // created with. + ScheduleAtStart: collection.SyncSchedule, + NextSyncAtAtStart: collection.NextSyncAt, }) - updated = collection - updated.LastSyncStatus = "failed" - updated.LastSyncMessage = syncErr.Error() + // Render what was stored, as a successful sync does; a schedule + // edited while the sync ran kept its own next run. + if updated, err = store.GetCollection(ctx, collection.ID); err != nil { + updated = collection + updated.LastSyncStatus = "failed" + updated.LastSyncMessage = syncErr.Error() + } } return UserImportView{ @@ -419,15 +440,19 @@ type MDBListDiscoveryView struct { Lists []mdblist.ListSummary `json:"lists"` } -// mdblistConfigured returns true when the discovery client is usable. When -// false it has already written a "not configured" 200 response so callers -// can simply early-return. +// MDBListConfigured reports whether MDBList search and top lists can answer, +// which needs an MDBList API key. v2 collection capabilities report it as +// mdblist_search. +func (h *UserCollectionImportHandler) MDBListConfigured() bool { + return h.mdblist != nil && h.mdblist.Configured() +} + // SearchMDBList answers the MDBList lists matching query. An unconfigured // MDBList client answers configured=false and no lists rather than an error, // so clients can hide the search box. v1 GET /collections/import/mdblist/search // and v2 searchMDBListLists both call it. func (h *UserCollectionImportHandler) SearchMDBList(ctx context.Context, query string) (MDBListDiscoveryView, error) { - if h.mdblist == nil || !h.mdblist.Configured() { + if !h.MDBListConfigured() { return MDBListDiscoveryView{Configured: false, Lists: []mdblist.ListSummary{}}, nil } query = strings.TrimSpace(query) @@ -444,7 +469,7 @@ func (h *UserCollectionImportHandler) SearchMDBList(ctx context.Context, query s // TopMDBList answers MDBList's most-liked lists; see SearchMDBList for the // unconfigured answer. func (h *UserCollectionImportHandler) TopMDBList(ctx context.Context) (MDBListDiscoveryView, error) { - if h.mdblist == nil || !h.mdblist.Configured() { + if !h.MDBListConfigured() { return MDBListDiscoveryView{Configured: false, Lists: []mdblist.ListSummary{}}, nil } lists, err := h.mdblist.Top(ctx) @@ -507,7 +532,7 @@ func (h *UserCollectionImportHandler) SyncPersonalCollection(ctx context.Context return nil, err } collection, err := store.GetCollection(ctx, collectionID) - if err != nil { + if err != nil || !collection.VisibleTo(profileID) { return nil, apiError(http.StatusNotFound, "not_found", "Collection not found") } if collection.CreatorProfileID != profileID { @@ -517,7 +542,7 @@ func (h *UserCollectionImportHandler) SyncPersonalCollection(ctx context.Context return nil, apiError(http.StatusConflict, "sync_in_flight", "A sync is already running for this collection") } - result, _, err := h.sync.RunSync(ctx, store, collection) + result, _, err := h.sync.RunSync(ctx, userID, store, collection) if err != nil { if errors.Is(err, usercollections.ErrSyncUnsupported) { return nil, apiError(http.StatusBadRequest, "bad_request", "This collection does not support sync") diff --git a/internal/api/handlers/user_collection_imports_mdblist_test.go b/internal/api/handlers/user_collection_imports_mdblist_test.go new file mode 100644 index 0000000000..eb3203473c --- /dev/null +++ b/internal/api/handlers/user_collection_imports_mdblist_test.go @@ -0,0 +1,34 @@ +package handlers + +import ( + "context" + "testing" + + "github.com/Silo-Server/silo-server/internal/mdblist" +) + +// TestMDBListConfiguredFollowsTheSearchClient checks that the capability +// answer agrees with what MDBList search does: no client or no API key means +// search reports configured=false, and an admin settings change applies +// without a restart. +func TestMDBListConfiguredFollowsTheSearchClient(t *testing.T) { + if NewUserCollectionImportHandler(nil, nil, nil, nil, nil, nil).MDBListConfigured() { + t.Fatal("handler without an MDBList client reports search configured") + } + client := mdblist.NewClient("", nil) + h := NewUserCollectionImportHandler(nil, nil, nil, nil, client, nil) + if h.MDBListConfigured() { + t.Fatal("client without an API key reports search configured") + } + if view, err := h.SearchMDBList(context.Background(), "top"); err != nil || view.Configured { + t.Fatalf("search without an API key = %+v, %v", view, err) + } + client.SetAPIKey("key") + if !h.MDBListConfigured() { + t.Fatal("client with an API key reports search not configured") + } + client.SetAPIKey(" ") + if h.MDBListConfigured() { + t.Fatal("clearing the API key left search configured") + } +} diff --git a/internal/api/router.go b/internal/api/router.go index 59b3611abe..a02c319fc1 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -272,6 +272,12 @@ type Dependencies struct { // Trakt / MDBList) — the user-facing analogue of CollectionService. UserCollectionSync *usercollections.Service UserCollectionScheduler *usercollections.Scheduler + // PersonalCollectionCollages serves and builds personal collection + // collages. main.go builds it beside UserCollectionSync, before scheduled + // syncs start, and hands it to both; the router gives it its generator + // once the poster signer exists. Nil builds one here when artwork + // storage is configured. + PersonalCollectionCollages *catalog.PersonalCollectionCollages // TrendingRefresher refreshes the persisted trending_discover snapshots. // Built in main.go with TMDB wired; its Trakt fetcher is propagated here in @@ -546,6 +552,7 @@ func newChiRouter(deps Dependencies) chi.Router { var metadataCurationAccess func(http.Handler) http.Handler var markerEditAccess func(http.Handler) http.Handler var viewerResolver apimw.ViewerResolver + var collectionOwners catalog.PersonalCollectionAccess var profileTokenService *access.ProfileTokenService var jwtService *auth.JWTService var sessionRepo *auth.SessionRepository @@ -630,6 +637,9 @@ func newChiRouter(deps Dependencies) chi.Router { viewerResolver = access.NewResolver(userRepo, deps.UserStoreProvider, profileTokenService, accessGroupStore).WithUnratedContentPolicy(unratedContent) } viewerAccessMiddleware = apimw.NewViewerAccessMiddleware(viewerResolver) + // Shared personal collections are limited to their owner's + // access, resolved by the same resolver the request gates use. + collectionOwners = usercollections.NewOwnerAccess(viewerResolver) } if deps.DB != nil { metadataLibraries := apimw.NewPGMetadataTargetLibraryResolver(deps.DB) @@ -881,6 +891,7 @@ func newChiRouter(deps Dependencies) chi.Router { if catalogSearchService != nil { itemsHandler.SetCatalogSearchProvider(catalogSearchService.Provider()) } + itemsHandler.SetPersonalCollectionAccess(collectionOwners) if deps.MarkerPopulation != nil { itemsHandler.MarkerPopulation = deps.MarkerPopulation } @@ -938,7 +949,8 @@ func newChiRouter(deps Dependencies) chi.Router { WithEpisodeRepository(episodeRepo). WithUserStoreProvider(deps.UserStoreProvider). WithSearchProvider(catalogSearchService.Provider()). - WithWatchlistPromoter(watchlistTitles) + WithWatchlistPromoter(watchlistTitles). + WithPersonalCollectionAccess(collectionOwners) catalogHandler = handlers.NewCatalogHandler(catalogResolver, itemsHandler) shuffleService = shuffle.NewService(deps.DB, catalogResolver) catalogHandler.SetWorkSummaryProvider(literaryRepo) @@ -1066,6 +1078,18 @@ func newChiRouter(deps Dependencies) chi.Router { var libraryPlaybackPrefHandler *handlers.LibraryPlaybackPrefHandler var watchProviderHandler *handlers.WatchProviderHandler var playbackSessionsLoader *handlers.PlaybackSessionsLoader + // Personal collections without an uploaded or imported poster show a + // collage of their titles; it needs artwork storage and poster signing. + var personalCollages *catalog.PersonalCollectionCollages + if deps.DB != nil && detailSvc != nil { + if gen := handlers.NewPersonalCollectionCollageGenerator(deps.Blobs.Assets, detailSvc, nil); gen != nil { + personalCollages = deps.PersonalCollectionCollages + if personalCollages == nil { + personalCollages = catalog.NewPersonalCollectionCollages(deps.DB, nil) + } + personalCollages.SetCollageGenerator(gen) + } + } if deps.DB != nil { playbackSessionsLoader = handlers.NewPlaybackSessionsLoader(deps.DB, deps.UserStoreProvider, detailSvc) } @@ -1123,6 +1147,11 @@ func newChiRouter(deps Dependencies) chi.Router { } collectionHandler.ArtworkStore = deps.Blobs.Assets collectionHandler.ArtworkResolver = deps.ArtworkResolver + if detailSvc != nil { + collectionHandler.ItemPosters = detailSvc + } + collectionHandler.CollectionOwners = collectionOwners + collectionHandler.Collages = personalCollages // The import handler is built beside the collection handler so the v1 // route group and the v2 operations share one instance; the v1 routes // keep their userImportHandler != nil condition. @@ -1849,6 +1878,7 @@ func newChiRouter(deps Dependencies) chi.Router { sectionBulkHandler = &handlers.SectionBulkHandler{Repo: sectionRepo} sectionFetcher := sections.NewFetcher(deps.DB) sectionFetcher.StoreProvider = deps.UserStoreProvider + sectionFetcher.CollectionOwners = collectionOwners if watchlistTitles != nil { sectionFetcher.WatchlistPromoter = watchlistTitles } @@ -1883,8 +1913,7 @@ func newChiRouter(deps Dependencies) chi.Router { sectionHandler.AccessGroups = accessGroupStore } if settingsRepo != nil { - sectionHandler.Settings = settingsRepo - sectionSettingsHandler = &handlers.SectionSettingsHandler{Settings: settingsRepo} + sectionSettingsHandler = &handlers.SectionSettingsHandler{} } libraryCollectionRepo := catalog.NewLibraryCollectionRepository(deps.DB) @@ -2001,6 +2030,8 @@ func newChiRouter(deps Dependencies) chi.Router { libraryCollectionHandler.Executor = &catalog.QueryExecutor{Pool: deps.DB} libraryCollectionHandler.SectionRepo = sectionRepo libraryCollectionHandler.UserCollectionPool = deps.DB + libraryCollectionHandler.CollectionOwners = collectionOwners + libraryCollectionHandler.PersonalCollages = personalCollages libraryCollectionHandler.EventsHub = deps.EventsHub libraryCollectionHandler.SortPreferenceCleaner = collectionSortCleaner if deps.FolderRepo != nil { @@ -2067,6 +2098,7 @@ func newChiRouter(deps Dependencies) chi.Router { if deps.DB != nil { recsFetcher := sections.NewFetcher(deps.DB) recsFetcher.StoreProvider = deps.UserStoreProvider + recsFetcher.CollectionOwners = collectionOwners if watchlistTitles != nil { recsFetcher.WatchlistPromoter = watchlistTitles } @@ -2850,10 +2882,6 @@ func newChiRouter(deps Dependencies) chi.Router { v2deps.ProfileSections = sectionHandler v2deps.AdminProfileSections = sectionHandler } - if sectionSettingsHandler != nil { - v2deps.SectionFlags = sectionSettingsHandler - v2deps.AdminSectionSettingsWrite = sectionSettingsHandler - } if webhookSyncHandler != nil { v2deps.WebhookSync = webhookSyncHandler v2deps.WebhookReceiver = webhookSyncHandler diff --git a/internal/api/testdata/media_routes.txt b/internal/api/testdata/media_routes.txt index 3c1aa8306a..c47a85bcbe 100644 --- a/internal/api/testdata/media_routes.txt +++ b/internal/api/testdata/media_routes.txt @@ -92,6 +92,7 @@ PUT /api/v2/admin/collections/{id}/items/order non-media DELETE /api/v2/admin/collections/{id}/items/{item_id} non-media PUT /api/v2/admin/collections/{id}/items/{item_id} non-media PUT /api/v2/admin/collections/{id}/poster non-media +GET /api/v2/admin/collections/{id}/sections non-media POST /api/v2/admin/collections/{id}/sync non-media GET /api/v2/admin/dashboard/capabilities non-media DELETE /api/v2/admin/dashboard/layout non-media @@ -326,8 +327,6 @@ PUT /api/v2/admin/settings non-media POST /api/v2/admin/settings/check/{kind} non-media GET /api/v2/admin/settings/effective non-media GET /api/v2/admin/settings/restart-keys non-media -GET /api/v2/admin/settings/sections non-media -PUT /api/v2/admin/settings/sections non-media GET /api/v2/admin/settings/sensitive-status non-media GET /api/v2/admin/settings/{key} non-media PUT /api/v2/admin/settings/{key} non-media @@ -1337,6 +1336,7 @@ PUT /api/v2/admin/collections/{id}/items/order non-media DELETE /api/v2/admin/collections/{id}/items/{item_id} non-media PUT /api/v2/admin/collections/{id}/items/{item_id} non-media PUT /api/v2/admin/collections/{id}/poster non-media +GET /api/v2/admin/collections/{id}/sections non-media POST /api/v2/admin/collections/{id}/sync non-media GET /api/v2/admin/dashboard/capabilities non-media DELETE /api/v2/admin/dashboard/layout non-media @@ -1571,8 +1571,6 @@ PUT /api/v2/admin/settings non-media POST /api/v2/admin/settings/check/{kind} non-media GET /api/v2/admin/settings/effective non-media GET /api/v2/admin/settings/restart-keys non-media -GET /api/v2/admin/settings/sections non-media -PUT /api/v2/admin/settings/sections non-media GET /api/v2/admin/settings/sensitive-status non-media GET /api/v2/admin/settings/{key} non-media PUT /api/v2/admin/settings/{key} non-media diff --git a/internal/apiv2/admin_account_profile_sections_test.go b/internal/apiv2/admin_account_profile_sections_test.go index fefb90fa46..786af01e8b 100644 --- a/internal/apiv2/admin_account_profile_sections_test.go +++ b/internal/apiv2/admin_account_profile_sections_test.go @@ -73,7 +73,7 @@ func (f *fakeAdminProfileSections) ResolveAccountProfileSectionSettings(_ contex return nil, err } return []sections.ResolvedSection{ - {ID: "s-continue", SectionType: "continue_watching", Title: "Continue Watching", ItemLimit: 20, Position: 0, Customized: true, Hidden: true}, + {ID: "s-continue", SectionType: "continue_watching", Title: "Continue Watching", DefaultTitle: "Continue Watching", ItemLimit: 20, Position: 0, Customized: true, Hidden: true}, {ID: "u-gems", SectionType: "hidden_gems", Title: "Hidden gems", ItemLimit: 12, Position: 1, IsCustom: true, Config: json.RawMessage(`{"library_ids":[3]}`)}, }, nil } diff --git a/internal/apiv2/admin_collection_extras.go b/internal/apiv2/admin_collection_extras.go index f95bc1145b..9b2901baa6 100644 --- a/internal/apiv2/admin_collection_extras.go +++ b/internal/apiv2/admin_collection_extras.go @@ -24,7 +24,7 @@ type AdminCollectionExtrasService interface { ImportAdminTMDB(context.Context, handlers.AdminCollectionImportTMDB) (handlers.AdminCollectionImportResult, error) ImportAdminTMDBList(context.Context, handlers.AdminCollectionImportTMDBList) (handlers.AdminCollectionImportResult, error) ImportAdminTrakt(context.Context, handlers.AdminCollectionImportTrakt) (handlers.AdminCollectionImportResult, error) - ListAdminCollectionTemplates(context.Context) (templates.BundleCatalog, error) + ListAdminCollectionTemplates(context.Context) ([]templates.BundleWithTemplates, error) AdminCollectionTemplateCatalog(context.Context) templates.Catalog ApplyAdminCollectionTemplate(context.Context, string, handlers.AdminCollectionTemplateApply) (handlers.AdminCollectionTemplateResult, error) QueueAdminCollectionTemplate(context.Context, string, handlers.AdminCollectionTemplateApply, int) (*models.AdminJob, error) @@ -87,7 +87,39 @@ type AdminCollectionImportOutput struct { } type AdminCollectionSyncOutput struct{ Body AdminCollectionSyncRun } type AdminTemplateCatalogOutput struct{ Body templates.Catalog } -type AdminBundleCatalogOutput struct{ Body templates.BundleCatalog } +type AdminBundleCatalogOutput struct{ Body BundleCatalog } + +// BundleCatalog is the /api/v2 bundle list. Each bundle adds a summary of its +// templates; /api/v1 serves templates.BundleCatalog without them. +type BundleCatalog struct { + Bundles []CollectionTemplateBundle `json:"bundles"` +} +type CollectionTemplateBundle struct { + templates.Bundle + Templates []CollectionTemplateSummary `json:"templates" doc:"The bundle's templates, in template_ids order."` +} +type CollectionTemplateSummary struct { + ID string `json:"id"` + Title string `json:"title"` + Source templates.Source `json:"source"` + MediaKind templates.MediaKind `json:"media_kind"` + Featured bool `json:"featured" doc:"Collections created from this template are pinned first on their shelf."` + PosterPath string `json:"poster_path,omitempty" doc:"Server-relative path of the template's poster image."` + NeedsSetup bool `json:"needs_setup" doc:"Applying the template creates an empty collection that cannot sync until an administrator sets its source."` +} + +func bundleCatalogOf(listed []templates.BundleWithTemplates) BundleCatalog { + out := BundleCatalog{Bundles: make([]CollectionTemplateBundle, 0, len(listed))} + for _, b := range listed { + summaries := make([]CollectionTemplateSummary, 0, len(b.Templates)) + for _, t := range b.Templates { + summaries = append(summaries, CollectionTemplateSummary{ID: t.ID, Title: t.Title, Source: t.Source, MediaKind: t.MediaKind, Featured: t.Featured, PosterPath: t.PosterPath, NeedsSetup: t.NeedsSetup()}) + } + out.Bundles = append(out.Bundles, CollectionTemplateBundle{Bundle: b.Bundle, Templates: summaries}) + } + return out +} + type AdminTemplateInput struct { BundleID string `path:"bundle_id"` Body AdminTemplateApply @@ -147,14 +179,14 @@ func registerAdminCollectionExtras(reg *Registry) { trakt := adminCollectionOperation(http.MethodPost, "/admin/collections/import/trakt", "importAdminTrakt", "Import a Trakt collection.", false) trakt.DefaultStatus = http.StatusCreated Register(reg, trakt, reg.importAdminTrakt) - Register(reg, adminCollectionOperation(http.MethodGet, "/admin/collections/templates", "listAdminCollectionTemplates", "List supported collection templates.", false), func(ctx context.Context, _ *struct{}) (*AdminTemplateCatalogOutput, error) { + Register(reg, adminCollectionOperation(http.MethodGet, "/admin/collections/templates", "listAdminCollectionTemplates", "List the collection templates with an mdblist, tmdb or tmdb_list source.", false), func(ctx context.Context, _ *struct{}) (*AdminTemplateCatalogOutput, error) { s, p := reg.adminCollectionExtras() if p != nil { return nil, p } - return &AdminTemplateCatalogOutput{Body: s.AdminCollectionTemplateCatalog(ctx)}, nil + return &AdminTemplateCatalogOutput{Body: creatableCollectionTemplates(s.AdminCollectionTemplateCatalog(ctx))}, nil }) - Register(reg, adminCollectionOperation(http.MethodGet, "/admin/collections/template-bundles", "listAdminCollectionTemplateBundles", "List collection template bundles.", false), func(ctx context.Context, _ *struct{}) (*AdminBundleCatalogOutput, error) { + Register(reg, adminCollectionOperation(http.MethodGet, "/admin/collections/template-bundles", "listAdminCollectionTemplateBundles", "List collection template bundles, each with a summary of its templates.", false), func(ctx context.Context, _ *struct{}) (*AdminBundleCatalogOutput, error) { s, p := reg.adminCollectionExtras() if p != nil { return nil, p @@ -163,7 +195,7 @@ func registerAdminCollectionExtras(reg *Registry) { if e != nil { return nil, adminCollectionError(e) } - return &AdminBundleCatalogOutput{Body: v}, nil + return &AdminBundleCatalogOutput{Body: bundleCatalogOf(v)}, nil }) Register(reg, adminCollectionOperation(http.MethodPost, "/admin/collections/template-bundles/{bundle_id}/apply", "applyAdminCollectionTemplateBundle", "Preview or synchronously apply a template bundle, preserving featured-section behavior.", false), reg.applyAdminTemplate) job := adminCollectionOperation(http.MethodPost, "/admin/collections/template-bundles/{bundle_id}/apply-job", "startAdminCollectionTemplateBundleJob", "Queue durable template application; poll its collection-job Location.", false) diff --git a/internal/apiv2/admin_collection_sections.go b/internal/apiv2/admin_collection_sections.go new file mode 100644 index 0000000000..d106261a21 --- /dev/null +++ b/internal/apiv2/admin_collection_sections.go @@ -0,0 +1,86 @@ +package apiv2 + +import ( + "context" + "net/http" + "strconv" + + "github.com/Silo-Server/silo-server/internal/api/handlers" +) + +// AdminCollectionSectionService reports the administrator Home and library +// page rows that show server collections. It reads page_sections only, so +// rows profiles added to their own Home are neither listed nor counted. +type AdminCollectionSectionService interface { + AdminCollectionSections(context.Context, string) ([]handlers.AdminCollectionSection, error) + AdminCollectionRowCounts(context.Context, []string) (map[string]handlers.AdminCollectionRowCount, error) +} + +type AdminCollectionSection struct { + ID ID `json:"id"` + Scope string `json:"scope" enum:"home,library"` + LibraryID *ID `json:"library_id" nullable:"true" doc:"The library whose page holds the row; null for a Home row."` + SectionType string `json:"section_type"` + Title string `json:"title"` + Featured bool `json:"featured" doc:"The row is its page's hero banner."` + Enabled bool `json:"enabled"` + Position int `json:"position"` + PageRowCount int `json:"page_row_count" doc:"Rows on the page that holds this row, turned-off rows included."` +} +type AdminCollectionSectionsOutput struct { + Body Collection[AdminCollectionSection] +} + +func registerAdminCollectionSections(reg *Registry) { + Register(reg, adminCollectionOperation(http.MethodGet, "/admin/collections/{id}/sections", "listAdminCollectionSections", "List the administrator Home and library page rows that show a collection. Rows profiles added themselves are not listed.", false), reg.listAdminCollectionSections) +} + +// adminCollectionSections returns the row-reference service, or nil when the +// configured admin collection service does not report rows. +func (reg *Registry) adminCollectionSections() AdminCollectionSectionService { + s, _ := reg.deps.AdminCollections.(AdminCollectionSectionService) + return s +} + +func (reg *Registry) listAdminCollectionSections(ctx context.Context, in *AdminCollectionIDInput) (*AdminCollectionSectionsOutput, error) { + s := reg.adminCollectionSections() + if s == nil { + return nil, unavailable("admin collection sections") + } + refs, err := s.AdminCollectionSections(ctx, string(in.ID)) + if err != nil { + return nil, adminCollectionError(err) + } + items := make([]AdminCollectionSection, 0, len(refs)) + for _, r := range refs { + item := AdminCollectionSection{ID: ID(r.ID), Scope: r.Scope, SectionType: string(r.SectionType), Title: r.Title, Featured: r.Featured, Enabled: r.Enabled, Position: r.Position, PageRowCount: r.PageRowCount} + if r.LibraryID != nil { + item.LibraryID = new(ID(strconv.Itoa(*r.LibraryID))) + } + items = append(items, item) + } + return &AdminCollectionSectionsOutput{Body: NewCollection(items)}, nil +} + +// withAdminCollectionRowCounts sets home_row_count and row_count on every +// listed collection from one grouped count. The counts stay absent when the +// service does not report rows. +func (reg *Registry) withAdminCollectionRowCounts(ctx context.Context, items []AdminCollection) error { + s := reg.adminCollectionSections() + if s == nil || len(items) == 0 { + return nil + } + ids := make([]string, 0, len(items)) + for _, c := range items { + ids = append(ids, string(c.ID)) + } + counts, err := s.AdminCollectionRowCounts(ctx, ids) + if err != nil || counts == nil { + return err + } + for i := range items { + count := counts[string(items[i].ID)] + items[i].HomeRowCount, items[i].RowCount = new(count.Home), new(count.Total) + } + return nil +} diff --git a/internal/apiv2/admin_collection_sections_test.go b/internal/apiv2/admin_collection_sections_test.go new file mode 100644 index 0000000000..b7737f3946 --- /dev/null +++ b/internal/apiv2/admin_collection_sections_test.go @@ -0,0 +1,242 @@ +package apiv2 + +import ( + "context" + "encoding/json" + "net/http" + "slices" + "testing" + + "github.com/Silo-Server/silo-server/internal/api/handlers" + catalogsvc "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/sections" +) + +func (f *fakeAdminCollections) ListAdminCollections(context.Context, *int) (handlers.AdminCollectionsList, error) { + f.listReads++ + return handlers.AdminCollectionsList{Collections: f.listed}, nil +} +func (f *fakeAdminCollections) AdminCollectionSections(_ context.Context, id string) ([]handlers.AdminCollectionSection, error) { + f.sectionReads++ + if id != "c1" { + return nil, catalogsvc.ErrLibraryCollectionNotFound + } + return f.sections, nil +} +func (f *fakeAdminCollections) AdminCollectionRowCounts(_ context.Context, ids []string) (map[string]handlers.AdminCollectionRowCount, error) { + f.countReads++ + f.countedIDs = append(f.countedIDs, ids...) + return f.rowCounts, nil +} + +// adminCollectionsWithoutRows hides the row-reference methods, like a service +// that cannot report rows. +type adminCollectionsWithoutRows struct { + AdminCollectionService +} + +const ( + adminCollectionsPath = "/api/v2/admin/collections" + adminCollectionSectionsPath = "/api/v2/admin/collections/c1/sections" +) + +func fakeRowReferences() *fakeAdminCollections { + f := newFakeAdminCollections() + stamp := fixedTime().Format("2006-01-02T15:04:05Z07:00") + listed := func(id, slug, title, kind string) handlers.AdminCollection { + empty := json.RawMessage(`{}`) + return handlers.AdminCollection{ID: id, LibraryID: 1, LibraryIDs: []int{1}, Slug: slug, Title: title, CollectionType: kind, Visibility: "visible", QueryDefinition: empty, SortConfig: empty, SourceConfig: empty, ManagementMode: "manual", CreatedAt: stamp, UpdatedAt: stamp} + } + f.listed = []handlers.AdminCollection{ + listed("c1", "on-home", "On Home", "manual"), + listed("c2", "library-page-only", "Library page only", "smart"), + listed("c3", "unused", "Unused", "manual"), + } + f.rowCounts = map[string]handlers.AdminCollectionRowCount{"c1": {Home: 2, Total: 3}, "c2": {Home: 0, Total: 1}} + kids := 7 + f.sections = []handlers.AdminCollectionSection{ + {PageSection: sections.PageSection{ID: "s-hero", Scope: "home", Position: 0, SectionType: sections.SectionCollection, Title: "Starter pack hero", Featured: true, Enabled: true}, PageRowCount: 9}, + {PageSection: sections.PageSection{ID: "s-home", Scope: "home", Position: 4, SectionType: sections.SectionCollection, Title: "On Home", Enabled: true}, PageRowCount: 9}, + {PageSection: sections.PageSection{ID: "s-kids", Scope: "library", LibraryID: &kids, Position: 2, SectionType: sections.SectionCollection, Title: "Kids", Enabled: false}, PageRowCount: 3}, + } + return f +} + +type listedRowCounts struct { + Items []struct { + ID string `json:"id"` + HomeRowCount *int `json:"home_row_count"` + RowCount *int `json:"row_count"` + } `json:"items"` +} + +// TestAdminCollectionListCarriesRowCounts checks that every listed collection +// carries its Home and total row counts from one grouped count for the whole +// page, with zero for a collection no row shows. +func TestAdminCollectionListCarriesRowCounts(t *testing.T) { + f := fakeRowReferences() + rec := do(t, adminCollectionsTestHandler(t, f), http.MethodGet, adminCollectionsPath+"?library_id=1", "", bearer(adminToken)) + if rec.Code != http.StatusOK { + t.Fatalf("%d %s", rec.Code, rec.Body) + } + var body listedRowCounts + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + want := map[string][2]int{"c1": {2, 3}, "c2": {0, 1}, "c3": {0, 0}} + if len(body.Items) != len(want) { + t.Fatalf("listed %d collections, want %d: %s", len(body.Items), len(want), rec.Body) + } + for _, item := range body.Items { + if item.HomeRowCount == nil || item.RowCount == nil { + t.Fatalf("%s: counts missing: %s", item.ID, rec.Body) + } + if got := [2]int{*item.HomeRowCount, *item.RowCount}; got != want[item.ID] { + t.Errorf("%s: home_row_count, row_count = %v, want %v", item.ID, got, want[item.ID]) + } + } + if f.countReads != 1 || !slices.Equal(f.countedIDs, []string{"c1", "c2", "c3"}) { + t.Fatalf("counted %d times for %v; want one count for the whole page", f.countReads, f.countedIDs) + } +} + +// TestAdminCollectionRowCountsStayOnTheList checks that the canonical editor +// read, whose ETag covers only the collection's own revision, never carries +// row counts, and that a service that cannot count rows leaves them out of the +// list instead of reporting zero. +func TestAdminCollectionRowCountsStayOnTheList(t *testing.T) { + f := fakeRowReferences() + rec := do(t, adminCollectionsTestHandler(t, f), http.MethodGet, "/api/v2/admin/collections/c1", "", bearer(adminToken)) + if rec.Code != http.StatusOK { + t.Fatalf("%d %s", rec.Code, rec.Body) + } + var one map[string]json.RawMessage + if err := json.Unmarshal(rec.Body.Bytes(), &one); err != nil { + t.Fatal(err) + } + for _, key := range []string{"home_row_count", "row_count"} { + if _, ok := one[key]; ok { + t.Errorf("getAdminCollection carries %s: %s", key, rec.Body) + } + } + if f.countReads != 0 { + t.Fatal("getAdminCollection counted rows") + } + + deps, _ := libraryDeps(t) + deps.AdminCollections = adminCollectionsWithoutRows{f} + rec = do(t, newTestHandler(t, deps), http.MethodGet, adminCollectionsPath, "", bearer(adminToken)) + if rec.Code != http.StatusOK { + t.Fatalf("%d %s", rec.Code, rec.Body) + } + var body listedRowCounts + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + for _, item := range body.Items { + if item.HomeRowCount != nil || item.RowCount != nil { + t.Fatalf("%s: counts reported without a row service: %s", item.ID, rec.Body) + } + } +} + +// TestAdminCollectionSectionsListTheRows checks the rows that show a +// collection: Home rows with a null library, library page rows with the +// library's string ID, the hero flag, turned-off rows and each page's row +// count, in the order the service returns them. +func TestAdminCollectionSectionsListTheRows(t *testing.T) { + f := fakeRowReferences() + rec := do(t, adminCollectionsTestHandler(t, f), http.MethodGet, adminCollectionSectionsPath, "", bearer(adminToken)) + if rec.Code != http.StatusOK { + t.Fatalf("%d %s", rec.Code, rec.Body) + } + var body struct { + Items []map[string]any `json:"items"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + want := []map[string]any{ + {"id": "s-hero", "scope": "home", "library_id": nil, "section_type": "collection", "title": "Starter pack hero", "featured": true, "enabled": true, "position": 0.0, "page_row_count": 9.0}, + {"id": "s-home", "scope": "home", "library_id": nil, "section_type": "collection", "title": "On Home", "featured": false, "enabled": true, "position": 4.0, "page_row_count": 9.0}, + {"id": "s-kids", "scope": "library", "library_id": "7", "section_type": "collection", "title": "Kids", "featured": false, "enabled": false, "position": 2.0, "page_row_count": 3.0}, + } + if len(body.Items) != len(want) { + t.Fatalf("got %d rows, want %d: %s", len(body.Items), len(want), rec.Body) + } + for i, w := range want { + got := body.Items[i] + if len(got) != len(w) { + t.Errorf("row %d members = %v, want %v", i, got, w) + } + for k, v := range w { + if got[k] != v { + t.Errorf("row %d %s = %v, want %v", i, k, got[k], v) + } + } + } + + f.sections = nil + rec = do(t, adminCollectionsTestHandler(t, f), http.MethodGet, adminCollectionSectionsPath, "", bearer(adminToken)) + var empty struct { + Items []json.RawMessage `json:"items"` + } + if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &empty) != nil || empty.Items == nil || len(empty.Items) != 0 { + t.Fatalf("unused collection: %d %s, want an empty items list", rec.Code, rec.Body) + } +} + +func TestAdminCollectionSectionsUnknownCollectionIsNotFound(t *testing.T) { + f := fakeRowReferences() + rec := do(t, adminCollectionsTestHandler(t, f), http.MethodGet, "/api/v2/admin/collections/missing/sections", "", bearer(adminToken)) + requireProblem(t, rec, TypeNotFound) + if f.sectionReads != 1 { + t.Fatalf("the service was asked %d times, want once", f.sectionReads) + } +} + +// TestAdminCollectionRowsRequireActingAdmin restates #193 S5 for the rows +// list and the counted collection list: a regular account and a non-primary +// profile on an admin account are refused before anything is read. +func TestAdminCollectionRowsRequireActingAdmin(t *testing.T) { + f := fakeRowReferences() + h := adminCollectionsTestHandler(t, f) + for _, path := range []string{adminCollectionSectionsPath, adminCollectionsPath} { + for _, headers := range []map[string]string{bearer(memberToken), with(bearer(adminToken), "X-Profile-Id", "p-owner")} { + requireProblem(t, do(t, h, http.MethodGet, path, "", headers), TypePermissionDenied) + } + } + if f.sectionReads+f.countReads+f.listReads+f.reads != 0 { + t.Fatalf("refused requests read rows %d, counts %d, lists %d, collections %d", f.sectionReads, f.countReads, f.listReads, f.reads) + } +} + +// TestAdminCollectionCapabilityReportsSectionReferences checks that the +// capability follows whether the service can report rows. +func TestAdminCollectionCapabilityReportsSectionReferences(t *testing.T) { + read := func(svc AdminCollectionService) bool { + t.Helper() + deps, _ := libraryDeps(t) + deps.AdminCollections = svc + rec := do(t, newTestHandler(t, deps), http.MethodGet, adminCollectionCapabilitiesPath, "", bearer(adminToken)) + if rec.Code != http.StatusOK { + t.Fatalf("%d %s", rec.Code, rec.Body) + } + var body struct { + SectionReferences *bool `json:"section_references"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.SectionReferences == nil { + t.Fatalf("section_references missing: %s", rec.Body) + } + return *body.SectionReferences + } + if !read(fakeRowReferences()) { + t.Error("section_references = false with a row service") + } + if read(adminCollectionsWithoutRows{fakeRowReferences()}) { + t.Error("section_references = true without a row service") + } +} diff --git a/internal/apiv2/admin_collection_template_bundles_test.go b/internal/apiv2/admin_collection_template_bundles_test.go new file mode 100644 index 0000000000..72774c2415 --- /dev/null +++ b/internal/apiv2/admin_collection_template_bundles_test.go @@ -0,0 +1,133 @@ +package apiv2 + +import ( + "context" + "encoding/json" + "net/http" + "slices" + "testing" + + "github.com/Silo-Server/silo-server/internal/collections/templates" + "github.com/Silo-Server/silo-server/internal/userstore" +) + +func (f *fakeAdminCollections) ListAdminCollectionTemplates(context.Context) ([]templates.BundleWithTemplates, error) { + f.bundleReads++ + if f.bundles != nil { + return f.bundles, nil + } + return templates.Default.BundlesWithTemplates(), nil +} + +const templateBundlesPath = "/api/v2/admin/collections/template-bundles" + +// TestAdminTemplateBundlesListTheirTemplates checks that every built-in bundle +// lists its templates in template_ids order, Discover and Franchise templates +// included, and that only the franchise placeholder needs setup. +func TestAdminTemplateBundlesListTheirTemplates(t *testing.T) { + h := adminCollectionsTestHandler(t, newFakeAdminCollections()) + rec := do(t, h, http.MethodGet, templateBundlesPath, "", bearer(adminToken)) + if rec.Code != http.StatusOK { + t.Fatalf("%d %s", rec.Code, rec.Body) + } + var body struct { + Bundles []struct { + ID string `json:"id"` + TemplateIDs []string `json:"template_ids"` + Templates []struct { + ID string `json:"id"` + Title string `json:"title"` + Source string `json:"source"` + MediaKind string `json:"media_kind"` + Featured *bool `json:"featured"` + PosterPath *string `json:"poster_path"` + NeedsSetup *bool `json:"needs_setup"` + } `json:"templates"` + } `json:"bundles"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if len(body.Bundles) != len(templates.ListBundles()) { + t.Fatalf("got %d bundles, want %d", len(body.Bundles), len(templates.ListBundles())) + } + sources := map[string]bool{} + for _, b := range body.Bundles { + ids := make([]string, 0, len(b.Templates)) + for _, s := range b.Templates { + ids = append(ids, s.ID) + sources[s.Source] = true + tmpl, ok := templates.Get(s.ID) + if !ok { + t.Fatalf("%s: unknown template %q", b.ID, s.ID) + } + if s.Title != tmpl.Title || s.Source != string(tmpl.Source) || s.MediaKind != string(tmpl.MediaKind) { + t.Errorf("%s/%s: summary %+v does not match the template", b.ID, s.ID, s) + } + if s.Featured == nil || *s.Featured != tmpl.Featured { + t.Errorf("%s/%s: featured %v, want %v", b.ID, s.ID, s.Featured, tmpl.Featured) + } + gotPoster := "" + if s.PosterPath != nil { + gotPoster = *s.PosterPath + } + if gotPoster != tmpl.PosterPath { + t.Errorf("%s/%s: poster_path %q, want %q", b.ID, s.ID, gotPoster, tmpl.PosterPath) + } + wantSetup := s.ID == "tmdb_franchise_placeholder" + if s.NeedsSetup == nil || *s.NeedsSetup != wantSetup { + t.Errorf("%s/%s: needs_setup %v, want %v", b.ID, s.ID, s.NeedsSetup, wantSetup) + } + } + if !slices.Equal(ids, b.TemplateIDs) { + t.Errorf("%s: templates %v, want template_ids %v", b.ID, ids, b.TemplateIDs) + } + } + for _, source := range []templates.Source{templates.SourceTMDBDiscover, templates.SourceTMDBCollection} { + if !sources[string(source)] { + t.Errorf("no bundle summary has source %s", source) + } + } +} + +// TestAdminTemplateBundlesRequireActingAdmin restates #193 S5 for the bundle +// list: a regular account and a non-primary profile on an admin account are +// refused before the catalog is read. +func TestAdminTemplateBundlesRequireActingAdmin(t *testing.T) { + f := newFakeAdminCollections() + h := adminCollectionsTestHandler(t, f) + for _, headers := range []map[string]string{bearer(memberToken), with(bearer(adminToken), "X-Profile-Id", "p-owner")} { + requireProblem(t, do(t, h, http.MethodGet, templateBundlesPath, "", headers), TypePermissionDenied) + } + if f.bundleReads != 0 { + t.Fatal("refused request read the bundle catalog") + } +} + +type featuredAdminCollections struct{ *fakeAdminCollections } + +func (featuredAdminCollections) AdminCollectionFeatures(context.Context) userstore.CollectionFeatures { + return userstore.CollectionFeatures{} +} + +// TestAdminCollectionCapabilitiesAdvertiseTemplateSummaries checks that a client +// can learn from the capability document, not the server version, that bundles +// carry their template summaries. +func TestAdminCollectionCapabilitiesAdvertiseTemplateSummaries(t *testing.T) { + deps, _ := libraryDeps(t) + deps.AdminCollections = featuredAdminCollections{newFakeAdminCollections()} + h := newTestHandler(t, deps) + rec := do(t, h, http.MethodGet, "/api/v2/admin/collections/capabilities", "", bearer(adminToken)) + if rec.Code != http.StatusOK { + t.Fatalf("%d %s", rec.Code, rec.Body) + } + var body struct { + TemplateSummaries *bool `json:"template_summaries"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.TemplateSummaries == nil || !*body.TemplateSummaries { + t.Fatalf("template_summaries not advertised: %s", rec.Body) + } +} diff --git a/internal/apiv2/admin_collection_templates_test.go b/internal/apiv2/admin_collection_templates_test.go new file mode 100644 index 0000000000..3886157816 --- /dev/null +++ b/internal/apiv2/admin_collection_templates_test.go @@ -0,0 +1,131 @@ +package apiv2 + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/Silo-Server/silo-server/internal/collections/templates" +) + +func (f *fakeAdminCollections) AdminCollectionTemplateCatalog(context.Context) templates.Catalog { + f.catalogReads++ + if f.catalog != nil { + return *f.catalog + } + return templates.CatalogDefault() +} + +const adminTemplatesPath = "/api/v2/admin/collections/templates" + +func getAdminTemplateCatalog(t *testing.T, f *fakeAdminCollections) templates.Catalog { + t.Helper() + rec := do(t, adminCollectionsTestHandler(t, f), http.MethodGet, adminTemplatesPath, "", bearer(adminToken)) + if rec.Code != http.StatusOK { + t.Fatalf("%d %s", rec.Code, rec.Body) + } + var body templates.Catalog + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + return body +} + +// TestAdminTemplateCatalogOffersOnlyCreatableTemplates checks that the admin +// template list holds only mdblist, tmdb and tmdb_list templates. TMDB +// Discover and franchise templates reach admins through Starter packs, so a +// single card for one of them could never be created. +func TestAdminTemplateCatalogOffersOnlyCreatableTemplates(t *testing.T) { + full := templates.CatalogDefault() + want := map[string]int{} + for _, group := range full.Categories { + for _, tmpl := range group.Templates { + switch tmpl.Source { + case templates.SourceMDBList, templates.SourceTMDB, templates.SourceTMDBList: + want[tmpl.ID]++ + } + } + } + if len(want) == 0 { + t.Fatal("built-in catalog has no creatable templates") + } + + got := getAdminTemplateCatalog(t, newFakeAdminCollections()) + kept := map[string]int{} + for _, group := range got.Categories { + if len(group.Templates) == 0 { + t.Errorf("category %q listed with no templates", group.Category) + } + for _, tmpl := range group.Templates { + kept[tmpl.ID]++ + if tmpl.Source == templates.SourceTMDBDiscover || tmpl.Source == templates.SourceTMDBCollection { + t.Errorf("template %s has source %s, which only a Starter pack can apply", tmpl.ID, tmpl.Source) + } + } + } + if len(kept) != len(want) { + t.Errorf("kept %d templates, want %d", len(kept), len(want)) + } + for id := range want { + if kept[id] != 1 { + t.Errorf("creatable template %s listed %d times, want once", id, kept[id]) + } + } +} + +// TestAdminTemplateCatalogDropsEmptiedCategories checks that a category whose +// templates are all bundle-only is left out rather than listed empty, and that +// the remaining categories keep their order. +func TestAdminTemplateCatalogDropsEmptiedCategories(t *testing.T) { + discover := templates.Template{ID: "discover_only", Title: "Discover only", Category: templates.CategoryTopRated, Source: templates.SourceTMDBDiscover, MediaKind: templates.MediaMovie, TMDBDiscover: &templates.TMDBDiscoverSpec{MediaType: "movie", SortBy: "vote_average.desc"}} + franchise := templates.Template{ID: "franchise", Title: "Franchise", Category: templates.CategoryEditorial, Source: templates.SourceTMDBCollection, MediaKind: templates.MediaMovie, TMDBCollection: &templates.TMDBCollectionSpec{CollectionID: 10}} + chart := templates.Template{ID: "chart", Title: "Chart", Category: templates.CategoryTrending, Source: templates.SourceTMDB, MediaKind: templates.MediaMovie, TMDB: &templates.TMDBSpec{Preset: "trending", MediaType: "movie", TimeWindow: "week"}} + list := templates.Template{ID: "list", Title: "List", Category: templates.CategoryCustom, Source: templates.SourceTMDBList, MediaKind: templates.MediaMixed, TMDBList: &templates.TMDBListSpec{}} + f := newFakeAdminCollections() + f.catalog = &templates.Catalog{Categories: []templates.CategoryGroup{ + {Category: templates.CategoryTrending, Label: "Trending", Templates: []templates.Template{chart}}, + {Category: templates.CategoryTopRated, Label: "Top Rated", Templates: []templates.Template{discover}}, + {Category: templates.CategoryEditorial, Label: "Editorial", Templates: []templates.Template{franchise}}, + {Category: templates.CategoryCustom, Label: "Custom", Templates: []templates.Template{list}}, + }} + + got := getAdminTemplateCatalog(t, f) + if len(got.Categories) != 2 { + t.Fatalf("categories %+v, want trending then custom", got.Categories) + } + for i, want := range []string{"chart", "list"} { + group := got.Categories[i] + if len(group.Templates) != 1 || group.Templates[0].ID != want { + t.Errorf("category %d = %s %+v, want only %s", i, group.Category, group.Templates, want) + } + } +} + +// TestBuiltinBundleOnlyTemplatesCarryTheirSource pins what the admin catalog +// filter relies on: every built-in TMDB Discover or franchise template is +// labeled with that source, so filtering by source catches all of them. +func TestBuiltinBundleOnlyTemplatesCarryTheirSource(t *testing.T) { + for _, tmpl := range templates.List() { + if tmpl.TMDBDiscover != nil && tmpl.Source != templates.SourceTMDBDiscover { + t.Errorf("%s has a tmdb_discover spec but source %s", tmpl.ID, tmpl.Source) + } + if tmpl.TMDBCollection != nil && tmpl.Source != templates.SourceTMDBCollection { + t.Errorf("%s has a tmdb_collection spec but source %s", tmpl.ID, tmpl.Source) + } + } +} + +// TestAdminTemplateCatalogRequiresActingAdmin restates #193 S5 for the +// template list: a regular account and a non-primary profile on an admin +// account are refused before the catalog is read. +func TestAdminTemplateCatalogRequiresActingAdmin(t *testing.T) { + f := newFakeAdminCollections() + h := adminCollectionsTestHandler(t, f) + for _, headers := range []map[string]string{bearer(memberToken), with(bearer(adminToken), "X-Profile-Id", "p-owner")} { + requireProblem(t, do(t, h, http.MethodGet, adminTemplatesPath, "", headers), TypePermissionDenied) + } + if f.catalogReads != 0 { + t.Fatal("refused request read the template catalog") + } +} diff --git a/internal/apiv2/admin_collection_types.go b/internal/apiv2/admin_collection_types.go index f5cbda11a0..58a85036ad 100644 --- a/internal/apiv2/admin_collection_types.go +++ b/internal/apiv2/admin_collection_types.go @@ -31,6 +31,7 @@ type AdminCollection struct { PosterURL string `json:"poster_url"` BackdropURL string `json:"backdrop_url"` PosterThumbhash string `json:"poster_thumbhash,omitempty"` + PosterIsCollage bool `json:"poster_is_collage" doc:"poster_url is the acting profile's collage of the collection's first members it can access, composed by the server because the collection has no uploaded or template poster. False for an uploaded or template poster and whenever poster_url is empty, including before the collage is built and on reads that carry no poster_url" example:"false"` BackdropThumbhash string `json:"backdrop_thumbhash,omitempty"` SourceURL string `json:"source_url"` QueryDefinition json.RawMessage `json:"query_definition"` @@ -45,6 +46,8 @@ type AdminCollection struct { SyncSchedule string `json:"sync_schedule,omitempty"` NextSyncAt *Instant `json:"next_sync_at,omitempty" nullable:"false"` ItemCount int `json:"item_count"` + HomeRowCount *int `json:"home_row_count,omitempty" nullable:"false" minimum:"0" doc:"Turned-on rows on the administrator Home page that show this collection; turned-off rows are not counted. Set on listAdminCollections items only. Rows profiles added themselves are not counted."` + RowCount *int `json:"row_count,omitempty" nullable:"false" minimum:"0" doc:"Rows on the administrator Home and library pages that show this collection, turned-off rows included. Set on listAdminCollections items only. Rows profiles added themselves are not counted."` CreatedAt Instant `json:"created_at"` UpdatedAt Instant `json:"updated_at"` } @@ -484,6 +487,7 @@ func adminCollectionOf(v handlers.AdminCollection) AdminCollection { PosterURL: v.PosterURL, BackdropURL: v.BackdropURL, PosterThumbhash: v.PosterThumbhash, + PosterIsCollage: v.PosterIsCollage && v.PosterURL != "", BackdropThumbhash: v.BackdropThumbhash, SourceURL: v.SourceURL, QueryDefinition: v.QueryDefinition, diff --git a/internal/apiv2/admin_collections.go b/internal/apiv2/admin_collections.go index 9d1388b334..d3cccff0df 100644 --- a/internal/apiv2/admin_collections.go +++ b/internal/apiv2/admin_collections.go @@ -126,9 +126,13 @@ type AdminCollectionCapabilityOutputBody struct { Groups bool `json:"groups"` Imports bool `json:"imports"` // ImportSources lists the sources a new collection can be imported from. - ImportSources []string `json:"import_sources" enum:"mdblist,tmdb,tmdb_list" doc:"Import sources a new collection can be created from; empty when imports is false" example:"[\"mdblist\",\"tmdb\",\"tmdb_list\"]"` - Artwork bool `json:"artwork"` - ItemReorder bool `json:"item_reorder"` + ImportSources []string `json:"import_sources" enum:"mdblist,tmdb,tmdb_list" doc:"Import sources a new collection can be created from; empty when imports is false" example:"[\"mdblist\",\"tmdb\",\"tmdb_list\"]"` + Artwork bool `json:"artwork"` + ItemReorder bool `json:"item_reorder"` + TemplateSummaries bool `json:"template_summaries" doc:"listAdminCollectionTemplateBundles returns each bundle's templates" example:"true"` + MDBListSearch bool `json:"mdblist_search" doc:"searchMDBListLists and listTopMDBListLists return lists; false when the server has no MDBList API key" example:"true"` + ScheduleTimeZone CollectionScheduleTimeZone `json:"schedule_time_zone"` + SectionReferences bool `json:"section_references" doc:"listAdminCollectionSections lists the rows that show a collection, and listAdminCollections items carry home_row_count and row_count" example:"true"` } func adminCollectionOperation(method, path, id, summary string, guarded bool) Operation { @@ -158,20 +162,26 @@ func registerAdminCollections(reg *Registry) { svc, ok := reg.deps.AdminCollections.(interface { AdminCollectionFeatures(context.Context) userstore.CollectionFeatures }) + out := &AdminCollectionCapabilityOutput{} + out.Body.MDBListSearch = reg.mdblistSearch() + out.Body.ScheduleTimeZone = reg.scheduleTimeZone() + out.Body.SectionReferences = reg.adminCollectionSections() != nil if !ok { - return &AdminCollectionCapabilityOutput{Body: AdminCollectionCapabilityOutputBody{Capability: Capability{State: StateNotConfigured}}}, nil + out.Body.State = StateNotConfigured + return out, nil } v := svc.AdminCollectionFeatures(ctx) - out := &AdminCollectionCapabilityOutput{} out.Body.Groups = v.Groups out.Body.Imports = v.Imports out.Body.ImportSources = collectionImportSources(v.Imports) out.Body.Artwork = v.Artwork out.Body.ItemReorder = v.ItemReorder + out.Body.TemplateSummaries = true return out, nil }) registerAdminCollectionGroups(reg) registerAdminCollectionExtras(reg) + registerAdminCollectionSections(reg) } func adminGroupOf(g handlers.AdminCollectionGroupView) AdminCollectionGroup { return AdminCollectionGroup{ID: ID(g.ID), LibraryID: IDFromInt(int64(g.LibraryID)), Name: g.Name, Slug: g.Slug, Kind: g.Kind, DefaultSortMode: g.DefaultSortMode, SortOrder: g.SortOrder} @@ -197,7 +207,7 @@ func adminCollectionError(err error) *Problem { return NewProblem(TypeValidationFailed, "ordered_ids must include every collection in the target group.") } switch { - case errors.Is(err, catalogsvc.ErrLibraryCollectionSyncModeUnsupported): + case errors.Is(err, catalogsvc.ErrLibraryCollectionSyncModeUnsupported), errors.Is(err, catalogsvc.ErrLibraryCollectionSyncUnsupported): return NewProblem(TypeValidationFailed, "This collection has no import source to synchronize.") case errors.Is(err, catalogsvc.ErrLibraryCollectionNotManual): return NewProblem(TypeConflict, "Only manual collections support manual items.") @@ -247,6 +257,9 @@ func (reg *Registry) listAdminCollections(ctx context.Context, in *AdminCollecti for _, c := range v.Collections { items = append(items, adminCollectionOf(c)) } + if e := reg.withAdminCollectionRowCounts(ctx, items); e != nil { + return nil, adminCollectionError(e) + } for _, g := range v.Groups { groups = append(groups, adminGroupOf(g)) } diff --git a/internal/apiv2/admin_collections_test.go b/internal/apiv2/admin_collections_test.go index 400de15bfd..738d312b09 100644 --- a/internal/apiv2/admin_collections_test.go +++ b/internal/apiv2/admin_collections_test.go @@ -11,6 +11,7 @@ import ( "github.com/Silo-Server/silo-server/internal/adminjob" "github.com/Silo-Server/silo-server/internal/api/handlers" catalogsvc "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/collections/templates" "github.com/Silo-Server/silo-server/internal/models" ) @@ -25,6 +26,16 @@ type fakeAdminCollections struct { job *models.AdminJob syncErr error template handlers.AdminCollectionTemplateResult + bundles []templates.BundleWithTemplates + bundleReads int + catalog *templates.Catalog + catalogReads int + featureReads int + listed []handlers.AdminCollection + listReads, sectionReads, countReads int + countedIDs []string + sections []handlers.AdminCollectionSection + rowCounts map[string]handlers.AdminCollectionRowCount } func newFakeAdminCollections() *fakeAdminCollections { @@ -271,6 +282,18 @@ func TestAdminCollectionSyncRejectsUnsupportedMode(t *testing.T) { } } +// A smart collection follows its rules and has no list to fetch, so a sync +// request for one is the same caller mistake and must not answer 500. +func TestAdminCollectionSyncRejectsSmartCollection(t *testing.T) { + f := newFakeAdminCollections() + f.syncErr = catalogsvc.ErrLibraryCollectionSyncUnsupported + h := adminCollectionsTestHandler(t, f) + p := requireProblem(t, do(t, h, http.MethodPost, "/api/v2/admin/collections/c1/sync", "", bearer(adminToken)), TypeValidationFailed) + if strings.Contains(p.Detail, catalogsvc.ErrLibraryCollectionSyncUnsupported.Error()) { + t.Fatalf("leaked service diagnostic: %s", p.Detail) + } +} + // The per-entry reason explains a skipped or failed template; v1 returns it and // v2 must reach the wire with it too. func TestAdminCollectionTemplateApplyKeepsEntryReason(t *testing.T) { diff --git a/internal/apiv2/admin_section_settings.go b/internal/apiv2/admin_section_settings.go deleted file mode 100644 index 7958a74840..0000000000 --- a/internal/apiv2/admin_section_settings.go +++ /dev/null @@ -1,74 +0,0 @@ -package apiv2 - -import ( - "context" - "errors" - "strconv" -) - -type AdminSectionSettingsWriteService interface { - UpdateAdminSectionSettings(context.Context, bool, func(bool) error) error -} -type AdminSectionSettingsReadInput struct { - IfMatch string `header:"If-Match"` - IfNoneMatch string `header:"If-None-Match"` -} -type AdminSectionSettingsWriteInput struct { - RawBody []byte - IfMatch string `header:"If-Match"` - IfNoneMatch string `header:"If-None-Match"` - Body ProfileSectionFlags -} -type AdminSectionSettingsOutput struct { - Status int - ETag string `header:"ETag"` - Body ProfileSectionFlags -} - -func adminSectionSettingsTag(ctx context.Context, enabled bool) EntityTag { - return RenderETag("admin-section-settings:"+strconv.Itoa(claimsFrom(ctx).UserID)+":"+profileFrom(ctx)+":"+viewerScopeDigest(ctx), strconv.FormatBool(enabled), 1) -} -func registerAdminSectionSettings(reg *Registry) { - read := Operation{Operation: humaOp("GET", Prefix+"/admin/settings/sections", "getAdminSectionSettings", "admin-settings", "Read section permission configuration; read failures retain the disabled default."), Class: ClassActingAdmin, ServiceBacked: true, Conditional: true} - Register(reg, read, func(ctx context.Context, in *AdminSectionSettingsReadInput) (*AdminSectionSettingsOutput, error) { - if reg.deps.SectionFlags == nil { - return nil, unavailable("section settings") - } - enabled := reg.deps.SectionFlags.AllowProfileCustomSections(ctx) - tag := adminSectionSettingsTag(ctx, enabled) - out := &AdminSectionSettingsOutput{ETag: tag.String(), Body: ProfileSectionFlags{AllowProfileCustomSections: enabled}} - if matched, p := EvaluateReadPreconditions(in.IfMatch, in.IfNoneMatch, tag); p != nil { - return nil, p - } else if matched { - return NotModified(out, tag), nil - } - return out, nil - }) - write := Operation{Operation: humaOp("PUT", Prefix+"/admin/settings/sections", "updateAdminSectionSettings", "admin-settings", "Replace section permission configuration under the existing settings transaction guard."), Class: ClassActingAdmin, DemoRestricted: true, ServiceBacked: true, Guarded: true, RetrySafety: RetrySafetyNaturalIdempotent} - Register(reg, write, func(ctx context.Context, in *AdminSectionSettingsWriteInput) (*AdminSectionSettingsOutput, error) { - if reg.deps.AdminSectionSettingsWrite == nil { - return nil, unavailable("section settings") - } - if p := rejectNonNullableNulls(in.RawBody, nil); p != nil { - return nil, p - } - enabled := in.Body.AllowProfileCustomSections - err := reg.deps.AdminSectionSettingsWrite.UpdateAdminSectionSettings(ctx, enabled, func(current bool) error { - tag := adminSectionSettingsTag(ctx, current) - if p := EvaluateGuardedPreconditions(in.IfMatch, in.IfNoneMatch, tag); p != nil { - if p.Status == 412 { - return StaleVersionProblem(tag) - } - return p - } - return nil - }) - if err != nil { - if p, ok := errors.AsType[*Problem](err); ok { - return nil, p - } - return nil, serviceProblem(err) - } - return &AdminSectionSettingsOutput{ETag: adminSectionSettingsTag(ctx, enabled).String(), Body: ProfileSectionFlags{AllowProfileCustomSections: enabled}}, nil - }) -} diff --git a/internal/apiv2/admin_section_settings_test.go b/internal/apiv2/admin_section_settings_test.go deleted file mode 100644 index 359ae21857..0000000000 --- a/internal/apiv2/admin_section_settings_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package apiv2 - -import ( - "context" - "testing" -) - -type fakeAdminSectionSettings struct { - enabled bool - calls int -} - -func (f *fakeAdminSectionSettings) AllowProfileCustomSections(context.Context) bool { return f.enabled } -func (f *fakeAdminSectionSettings) UpdateAdminSectionSettings(_ context.Context, enabled bool, guard func(bool) error) error { - f.calls++ - if err := guard(f.enabled); err != nil { - return err - } - f.enabled = enabled - return nil -} -func TestAdminSectionSettingsGuard(t *testing.T) { - deps := pilotDeps(nil, nil) - f := new(fakeAdminSectionSettings) - deps.SectionFlags = f - deps.AdminSectionSettingsWrite = f - h := NewHandler(deps) - path := Prefix + "/admin/settings/sections" - requireProblem(t, do(t, h, "PUT", path, `{"allow_profile_custom_sections":true}`, bearer(memberToken)), TypePermissionDenied) - if f.calls != 0 { - t.Fatal("unauthorized write") - } - rec := do(t, h, "GET", path, "", bearer(adminToken)) - falseTag := rec.Header().Get("ETag") - if rec.Code != 200 || falseTag == "" { - t.Fatal(rec.Code, rec.Body.String()) - } - headers := bearer(adminToken) - headers["If-None-Match"] = falseTag - rec = do(t, h, "GET", path, "", headers) - if rec.Code != 304 || rec.Body.Len() != 0 { - t.Fatal(rec.Code, rec.Body.String()) - } - requireProblem(t, do(t, h, "PUT", path, `{"allow_profile_custom_sections":true}`, bearer(adminToken)), TypePreconditionRequired) - headers = bearer(adminToken) - headers["If-Match"] = falseTag - rec = do(t, h, "PUT", path, `{"allow_profile_custom_sections":true}`, headers) - trueTag := rec.Header().Get("ETag") - if rec.Code != 200 || !f.enabled || trueTag == falseTag { - t.Fatal(rec.Code, rec.Body.String()) - } - rec = do(t, h, "PUT", path, `{"allow_profile_custom_sections":false}`, headers) - if rec.Code != 412 || !f.enabled { - t.Fatal("stale write was applied", rec.Code) - } - headers["If-Match"] = "*" - headers["If-None-Match"] = trueTag - rec = do(t, h, "PUT", path, `{"allow_profile_custom_sections":false}`, headers) - if rec.Code != 412 || !f.enabled { - t.Fatal("wildcard ignored exclusion", rec.Code) - } - delete(headers, "If-None-Match") - requireProblem(t, do(t, h, "PUT", path, `{"allow_profile_custom_sections":null}`, headers), TypeValidationFailed) - requireProblem(t, do(t, h, "PUT", path, `{}`, headers), TypeValidationFailed) - rec = do(t, h, "PUT", path, `{"allow_profile_custom_sections":false}`, headers) - if rec.Code != 200 || f.enabled { - t.Fatal(rec.Code, rec.Body.String()) - } - deps.AdminSectionSettingsWrite = nil - requireProblem(t, do(t, NewHandler(deps), "PUT", path, `{"allow_profile_custom_sections":true}`, headers), TypeDependencyUnavailable) -} diff --git a/internal/apiv2/admin_sections.go b/internal/apiv2/admin_sections.go index cea0da7b8d..2c03c500f7 100644 --- a/internal/apiv2/admin_sections.go +++ b/internal/apiv2/admin_sections.go @@ -127,7 +127,7 @@ func (reg *Registry) createAdminSection(ctx context.Context, in *AdminSectionCre if p != nil { return nil, p } - v, err := reg.deps.AdminSections.CreateAdminSection(ctx, handlers.AdminSectionCreate{Scope: in.Body.Scope, LibraryID: library, Position: in.Body.Position, SectionType: in.Body.SectionType, Title: in.Body.Title, Featured: in.Body.Featured, ItemLimit: in.Body.ItemLimit, Config: adminSectionConfig(in.Body.Config), Enabled: in.Body.Enabled}) + v, err := reg.deps.AdminSections.CreateAdminSection(ctx, handlers.AdminSectionCreate{Scope: in.Body.Scope, LibraryID: library, Position: in.Body.Position, SectionType: in.Body.SectionType, Title: in.Body.Title, Featured: in.Body.Featured, ItemLimit: in.Body.ItemLimit, Config: adminSectionConfig(in.Body.Config), Enabled: in.Body.Enabled, ValidateRecipe: true}) if err != nil { return nil, adminSectionError(err) } @@ -166,7 +166,7 @@ func (reg *Registry) updateAdminSection(ctx context.Context, in *AdminSectionUpd if p := rejectNonNullableNulls(in.RawBody, nil); p != nil { return nil, p } - cmd := handlers.AdminSectionUpdate{Position: in.Body.Position, Featured: in.Body.Featured, ItemLimit: in.Body.ItemLimit, Config: adminSectionConfig(in.Body.Config), Enabled: in.Body.Enabled} + cmd := handlers.AdminSectionUpdate{Position: in.Body.Position, Featured: in.Body.Featured, ItemLimit: in.Body.ItemLimit, Config: adminSectionConfig(in.Body.Config), Enabled: in.Body.Enabled, ValidateRecipe: true} if in.Body.Title != nil { cmd.Title = *in.Body.Title } diff --git a/internal/apiv2/admin_sections_extras.go b/internal/apiv2/admin_sections_extras.go index 5e8f1498e0..b54a81b1da 100644 --- a/internal/apiv2/admin_sections_extras.go +++ b/internal/apiv2/admin_sections_extras.go @@ -90,8 +90,9 @@ func (reg *Registry) previewAdminSection(ctx context.Context, in *AdminSectionPr continue } // Preview models carry internal artwork storage keys, not public URLs. - // Expose the stable catalog card fields, without serializing storage state. - out.Items = append(out.Items, withShownRatings(CatalogItem{ContentID: item.ContentID, Type: item.Type, Title: item.Title, PlayContentID: item.PlayContentID, Year: item.Year, Runtime: item.Runtime, Genres: NonNil(item.Genres), Keywords: NonNil(item.Keywords), Studios: item.Studios, Networks: item.Networks, ContentRating: item.ContentRating, AdvisoryAge: item.AdvisoryAge, AdvisorySource: item.AdvisorySource, Status: item.Status, ShowStatus: item.ShowStatus, RatingIMDB: item.RatingIMDB, RatingTMDB: item.RatingTMDB, RatingRTCritic: item.RatingRTCritic, RatingRTAudience: item.RatingRTAudience, OriginalLanguage: item.OriginalLanguage, Overview: item.Overview, ReleaseDate: item.ReleaseDate, LastAirDate: item.LastAirDate, PosterThumbhash: item.PosterThumbhash, BackdropThumbhash: item.BackdropThumbhash}, sel)) + // Expose the stable catalog card fields and the poster the service + // presigned, without serializing storage state. + out.Items = append(out.Items, withShownRatings(CatalogItem{ContentID: item.ContentID, Type: item.Type, Title: item.Title, PlayContentID: item.PlayContentID, Year: item.Year, Runtime: item.Runtime, Genres: NonNil(item.Genres), Keywords: NonNil(item.Keywords), Studios: item.Studios, Networks: item.Networks, ContentRating: item.ContentRating, AdvisoryAge: item.AdvisoryAge, AdvisorySource: item.AdvisorySource, Status: item.Status, ShowStatus: item.ShowStatus, RatingIMDB: item.RatingIMDB, RatingTMDB: item.RatingTMDB, RatingRTCritic: item.RatingRTCritic, RatingRTAudience: item.RatingRTAudience, OriginalLanguage: item.OriginalLanguage, Overview: item.Overview, ReleaseDate: item.ReleaseDate, LastAirDate: item.LastAirDate, PosterURL: value.PosterURLs[item.ContentID], PosterThumbhash: item.PosterThumbhash, BackdropThumbhash: item.BackdropThumbhash}, sel)) } return &AdminSectionPreviewOutput{Body: out}, nil } diff --git a/internal/apiv2/admin_sections_preview_db_test.go b/internal/apiv2/admin_sections_preview_db_test.go new file mode 100644 index 0000000000..61269858d1 --- /dev/null +++ b/internal/apiv2/admin_sections_preview_db_test.go @@ -0,0 +1,101 @@ +package apiv2 + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + "time" + + "github.com/Silo-Server/silo-server/internal/access" + "github.com/Silo-Server/silo-server/internal/api/handlers" + catalogpkg "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/sections" +) + +// prefixImageResolver signs a path by prefixing it, so a test can see which +// stored variant a response presigned. +type prefixImageResolver struct{} + +func (prefixImageResolver) ResolveImageURL(_ context.Context, path, variant string) string { + return "https://img.example/" + variant + "/" + path +} + +func (r prefixImageResolver) ResolveImageURLs(ctx context.Context, paths []string, variant string) map[string]string { + out := make(map[string]string, len(paths)) + for _, path := range paths { + out[path] = r.ResolveImageURL(ctx, path, variant) + } + return out +} + +// TestAdminSectionPreviewPosterMatchesLiveRowDB: a preview over the real +// fetcher returns the same presigned poster the saved row serves the viewer, +// and never the storage key. +func TestAdminSectionPreviewPosterMatchesLiveRowDB(t *testing.T) { + pool := viewerAccessTestPool(t) + suffix := time.Now().UnixNano() + libraryID := seedLibrary(t, pool, fmt.Sprintf("preview-posters-%d", suffix)) + withPoster := fmt.Sprintf("preview-poster-%d-a", suffix) + without := fmt.Sprintf("preview-poster-%d-b", suffix) + t.Cleanup(func() { + mustExec(t, pool, `DELETE FROM media_items WHERE content_id = ANY($1)`, []string{withPoster, without}) + }) + mustExec(t, pool, `INSERT INTO media_items (content_id, type, title, poster_path, poster_thumbhash) VALUES ($1, 'movie', 'With poster', 'images/preview/poster/original.jpg', 'hash-a'), ($2, 'movie', 'Without poster', '', '')`, withPoster, without) + mustExec(t, pool, `INSERT INTO media_item_libraries (content_id, media_folder_id) VALUES ($1, $3), ($2, $3)`, withPoster, without, libraryID) + + detail := &catalogpkg.DetailService{} + detail.SetImageResolver(prefixImageResolver{}) + svc := handlers.NewSectionHandler(sections.NewRepository(pool), sections.NewFetcher(pool)) + svc.DetailSvc = detail + deps := scopedViewerDeps(t, &access.Scope{}, svc, &fakeLibraryViews{}) + deps.AdminSections = svc + h := newTestHandler(t, deps) + + type item struct { + ContentID string `json:"content_id"` + PosterURL *string `json:"poster_url"` + PosterThumbhash string `json:"poster_thumbhash"` + } + postersOf := func(items []item) map[string]*string { + out := map[string]*string{} + for _, it := range items { + out[it.ContentID] = it.PosterURL + } + return out + } + + rec := do(t, h, http.MethodPost, Prefix+"/admin/sections/preview", fmt.Sprintf(`{"section_type":"recently_added","config":{},"item_limit":3,"library_id":"%d"}`, libraryID), bearer(adminToken)) + if rec.Code != 200 { + t.Fatalf("preview %d %s", rec.Code, rec.Body) + } + var preview struct { + Items []item `json:"items"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &preview); err != nil { + t.Fatal(err) + } + previewPosters := postersOf(preview.Items) + signed := previewPosters[withPoster] + if signed == nil || *signed != "https://img.example/featured/images/preview/poster/w500.jpg" { + t.Fatalf("preview poster for %s = %v; body %s", withPoster, signed, rec.Body) + } + if got, ok := previewPosters[without]; !ok || got != nil { + t.Fatalf("item without artwork: present=%t poster=%v; body %s", ok, got, rec.Body) + } + + live := do(t, h, http.MethodGet, fmt.Sprintf("/api/v2/library/%d/sections/default-recently-added/items", libraryID), "", viewerHeaders()) + if live.Code != 200 { + t.Fatalf("live row %d %s", live.Code, live.Body) + } + var row struct { + Items []item `json:"items"` + } + if err := json.Unmarshal(live.Body.Bytes(), &row); err != nil { + t.Fatal(err) + } + if got := postersOf(row.Items)[withPoster]; got == nil || *got != *signed { + t.Fatalf("live row poster %v, preview poster %s; body %s", got, *signed, live.Body) + } +} diff --git a/internal/apiv2/admin_sections_test.go b/internal/apiv2/admin_sections_test.go index 15d19ee534..c4a8b3a05c 100644 --- a/internal/apiv2/admin_sections_test.go +++ b/internal/apiv2/admin_sections_test.go @@ -22,7 +22,9 @@ type fakeAdminSections struct { reads, updates, deletes, creates, reorders, restores, bulks, previews int mismatch, readMismatch, deleted bool preview []*models.MediaItem + previewPosters map[string]string previewRequest handlers.AdminSectionPreviewRequest + recipeChecks []bool } func newFakeAdminSections() *fakeAdminSections { @@ -54,6 +56,7 @@ func (f *fakeAdminSections) AdminSectionOrder(_ context.Context, scope string, l } func (f *fakeAdminSections) CreateAdminSection(_ context.Context, in handlers.AdminSectionCreate) (handlers.AdminSection, error) { f.creates++ + f.recipeChecks = append(f.recipeChecks, in.ValidateRecipe) f.view.Scope = in.Scope f.view.LibraryID = in.LibraryID f.view.Title = in.Title @@ -62,6 +65,7 @@ func (f *fakeAdminSections) CreateAdminSection(_ context.Context, in handlers.Ad } func (f *fakeAdminSections) UpdateAdminSection(_ context.Context, _ string, in handlers.AdminSectionUpdate) (handlers.AdminSection, error) { f.updates++ + f.recipeChecks = append(f.recipeChecks, in.ValidateRecipe) f.revision++ f.scopeRevision++ if f.mismatch { @@ -124,7 +128,7 @@ func (f *fakeAdminSections) PreviewAdminSection(_ context.Context, req handlers. if req.LibraryIDs != nil && len(req.LibraryIDs) == 0 { return handlers.AdminSectionPreviewResult{}, nil } - return handlers.AdminSectionPreviewResult{Items: f.preview, TotalCount: len(f.preview)}, nil + return handlers.AdminSectionPreviewResult{Items: f.preview, TotalCount: len(f.preview), PosterURLs: f.previewPosters}, nil } func (f *fakeAdminSections) AdminSectionCapabilities(context.Context) handlers.AdminSectionCapabilitiesView { return f.caps @@ -197,6 +201,24 @@ func TestAdminSectionsDatabaseConflictAndReadRace(t *testing.T) { f.readMismatch = true requireProblem(t, do(t, h, http.MethodGet, path, "", bearer(adminToken)), TypeConflict) } + +// v2 writes run the section type's own config check; the shared service skips +// it for the frozen /api/v1 routes. +func TestAdminSectionsWritesRunTheRecipeCheck(t *testing.T) { + f := newFakeAdminSections() + h := adminSectionsTestHandler(t, f) + created := do(t, h, http.MethodPost, Prefix+"/admin/sections", `{"scope":"home","section_type":"recently_added","title":"New","config":{}}`, bearer(adminToken)) + if created.Code != 201 { + t.Fatalf("create %d %s", created.Code, created.Body) + } + updated := do(t, h, http.MethodPatch, Prefix+"/admin/sections/s1", `{"title":"Edited"}`, with(bearer(adminToken), "If-Match", "*")) + if updated.Code != 200 { + t.Fatalf("update %d %s", updated.Code, updated.Body) + } + if !slices.Equal(f.recipeChecks, []bool{true, true}) { + t.Fatalf("recipe checks requested = %v, want create and update", f.recipeChecks) + } +} func TestAdminSectionsNullsScopeAndConfig(t *testing.T) { f := newFakeAdminSections() h := adminSectionsTestHandler(t, f) @@ -391,3 +413,38 @@ func TestAdminSectionPreviewLeavesAbsentLibraryScopeUnscoped(t *testing.T) { }) } } + +// Preview items carry the presigned poster the service resolved, never the +// storage key it came from; an item without one omits poster_url. +func TestAdminSectionPreviewServesPresignedPosters(t *testing.T) { + f := newFakeAdminSections() + f.preview = []*models.MediaItem{ + {ContentID: "item1", Title: "Signed", Type: "movie", PosterPath: "private/storage/poster", PosterThumbhash: "hash1"}, + {ContentID: "item2", Title: "Bare", Type: "movie"}, + } + f.previewPosters = map[string]string{"item1": "https://cdn.example/signed.jpg?sig=1"} + h := adminSectionsTestHandler(t, f) + rec := do(t, h, http.MethodPost, Prefix+"/admin/sections/preview", `{"section_type":"recently_added","config":{},"item_limit":3}`, bearer(adminToken)) + if rec.Code != 200 || strings.Contains(rec.Body.String(), "private/") { + t.Fatalf("preview %d %s", rec.Code, rec.Body) + } + var body struct { + Items []struct { + ContentID string `json:"content_id"` + PosterURL *string `json:"poster_url"` + PosterThumbhash string `json:"poster_thumbhash"` + } `json:"items"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if len(body.Items) != 2 { + t.Fatalf("items %s", rec.Body) + } + if got := body.Items[0]; got.ContentID != "item1" || got.PosterURL == nil || *got.PosterURL != "https://cdn.example/signed.jpg?sig=1" || got.PosterThumbhash != "hash1" { + t.Fatalf("signed item %s", rec.Body) + } + if got := body.Items[1]; got.ContentID != "item2" || got.PosterURL != nil { + t.Fatalf("item without a poster %s", rec.Body) + } +} diff --git a/internal/apiv2/admin_settings_discovery_test.go b/internal/apiv2/admin_settings_discovery_test.go index 0e8cc1fc87..8bc2d4008c 100644 --- a/internal/apiv2/admin_settings_discovery_test.go +++ b/internal/apiv2/admin_settings_discovery_test.go @@ -20,9 +20,8 @@ func TestAdminSettingsDiscovery(t *testing.T) { deps := pilotDeps(nil, nil) f := new(fakeAdminSettingRead) deps.AdminSettingRead = f - deps.SectionFlags = &handlers.SectionSettingsHandler{} h := NewHandler(deps) - for _, path := range []string{"settings/redis.url", "settings/sections", "playback-routing/capabilities"} { + for _, path := range []string{"settings/redis.url", "playback-routing/capabilities"} { requireProblem(t, do(t, h, "GET", Prefix+"/admin/"+path, "", bearer(memberToken)), TypePermissionDenied) } if f.calls != 0 { @@ -33,10 +32,6 @@ func TestAdminSettingsDiscovery(t *testing.T) { t.Fatal(rec.Code, rec.Body.String()) } requireProblem(t, do(t, h, "GET", Prefix+"/admin/settings/missing", "", bearer(adminToken)), TypeNotFound) - rec = do(t, h, "GET", Prefix+"/admin/settings/sections", "", bearer(adminToken)) - if rec.Code != 200 || !strings.Contains(rec.Body.String(), `"allow_profile_custom_sections":false`) { - t.Fatal("static path was not routed", rec.Code, rec.Body.String()) - } rec = do(t, h, "GET", Prefix+"/admin/playback-routing/capabilities", "", bearer(adminToken)) if rec.Code != 200 || !strings.Contains(rec.Body.String(), `"playback_node_routing_v1"`) || !strings.Contains(rec.Body.String(), `"worker_only"`) { t.Fatal(rec.Code, rec.Body.String()) diff --git a/internal/apiv2/catalog_facet_search_db_test.go b/internal/apiv2/catalog_facet_search_db_test.go new file mode 100644 index 0000000000..ece7224194 --- /dev/null +++ b/internal/apiv2/catalog_facet_search_db_test.go @@ -0,0 +1,91 @@ +package apiv2 + +import ( + "context" + "fmt" + "net/http" + "reflect" + "testing" + "time" + + "github.com/Silo-Server/silo-server/internal/api/handlers" + catalogpkg "github.com/Silo-Server/silo-server/internal/catalog" +) + +// allowlistCatalogAccess resolves every viewer to one library allowlist; +// nil means unrestricted. +type allowlistCatalogAccess struct{ allowed []int } + +func (a *allowlistCatalogAccess) ContextAccessFilter(ctx context.Context, _ handlers.AccessFilterOptions) (catalogpkg.AccessFilter, error) { + return catalogpkg.AccessFilter{UserID: claimsFrom(ctx).UserID, ProfileID: "p-owner", AllowedLibraryIDs: a.allowed}, nil +} + +// TestSearchCatalogFacetLibraryIDsDB: library_ids narrows the facet scope to +// the named libraries the viewer may see and never widens it, and values +// carry each value's title count. +func TestSearchCatalogFacetLibraryIDsDB(t *testing.T) { + pool := viewerAccessTestPool(t) + sfx := fmt.Sprint(time.Now().UnixNano()) + libA := seedLibrary(t, pool, "facet-a-"+sfx) + libB := seedLibrary(t, pool, "facet-b-"+sfx) + ids := []string{"facet-a1-" + sfx, "facet-a2-" + sfx, "facet-b1-" + sfx} + t.Cleanup(func() { mustExec(t, pool, `DELETE FROM media_items WHERE content_id = ANY($1)`, ids) }) + studioA, studioB := "Facet Studio A "+sfx, "Facet Studio B "+sfx + mustExec(t, pool, `INSERT INTO media_items (content_id, type, title, status, studios) VALUES ($1, 'movie', 'A1', 'matched', $4), ($2, 'movie', 'A2', 'matched', $4), ($3, 'movie', 'B1', 'matched', $5)`, + ids[0], ids[1], ids[2], []string{studioA}, []string{studioB}) + mustExec(t, pool, `INSERT INTO media_item_libraries (content_id, media_folder_id) VALUES ($1, $3), ($2, $3)`, ids[0], ids[1], libA) + mustExec(t, pool, `INSERT INTO media_item_libraries (content_id, media_folder_id) VALUES ($1, $2)`, ids[2], libB) + + browseRepo := catalogpkg.NewBrowseRepository(pool) + itemRepo := catalogpkg.NewItemRepository(pool) + itemsH := handlers.NewItemsHandler(browseRepo, itemRepo, nil, nil, nil, nil, nil, nil, nil) + access := &allowlistCatalogAccess{allowed: []int{libA}} + deps, _ := catalogDeps(t) + deps.CatalogAccess = access + deps.CatalogBrowse = handlers.NewCatalogHandler(catalogpkg.NewCatalogResolver(browseRepo, itemRepo), itemsH) + h := newTestHandler(t, deps) + + type answer struct { + Matches []string `json:"matches"` + Values []CatalogFacetValue `json:"values"` + HasMore bool `json:"has_more"` + } + search := func(query string) answer { + t.Helper() + rec := do(t, h, http.MethodGet, "/api/v2/catalog/filters/search?facet=studio&q=facet+studio&"+query, "", viewerHeaders()) + if rec.Code != 200 { + t.Fatalf("%s: %d %s", query, rec.Code, rec.Body.String()) + } + var got answer + decodeJSON(t, rec.Body, &got) + return got + } + onlyA := answer{Matches: []string{studioA}, Values: []CatalogFacetValue{{Value: studioA, Count: 2}}} + none := answer{Matches: []string{}, Values: []CatalogFacetValue{}} + both := answer{Matches: []string{studioA, studioB}, Values: []CatalogFacetValue{{Value: studioA, Count: 2}, {Value: studioB, Count: 1}}} + + for _, tc := range []struct { + name string + query string + want answer + }{ + {"both requested", fmt.Sprintf("library_ids=%d&library_ids=%d", libA, libB), onlyA}, + {"only the hidden library", fmt.Sprintf("library_ids=%d", libB), none}, + {"hidden library through library_id", fmt.Sprintf("library_id=%d&library_ids=%d", libB, libB), none}, + {"no library_ids", "", onlyA}, + } { + if got := search(tc.query); !reflect.DeepEqual(got, tc.want) { + t.Errorf("restricted viewer, %s: got %+v, want %+v", tc.name, got, tc.want) + } + } + + // The same scope parameters from an unrestricted viewer reach both + // libraries: the cache keys on the access predicates, not the request. + access.allowed = nil + if got := search(fmt.Sprintf("library_ids=%d&library_ids=%d", libA, libB)); !reflect.DeepEqual(got, both) { + t.Errorf("unrestricted viewer: got %+v, want %+v", got, both) + } + if got := search(fmt.Sprintf("library_ids=%d", libB)); !reflect.DeepEqual(got, answer{Matches: []string{studioB}, Values: []CatalogFacetValue{{Value: studioB, Count: 1}}}) { + t.Errorf("unrestricted viewer, library B: got %+v", got) + } +} diff --git a/internal/apiv2/catalog_items.go b/internal/apiv2/catalog_items.go index a68670deed..7fa84ec687 100644 --- a/internal/apiv2/catalog_items.go +++ b/internal/apiv2/catalog_items.go @@ -7,6 +7,7 @@ import ( "log/slog" "net/http" "net/url" + "slices" "strconv" "strings" "time" @@ -62,6 +63,7 @@ type CatalogFiltersInput struct { Scope string `query:"scope" enum:"home,library"` SectionID string `query:"section_id"` LibraryID ID `query:"library_id" example:"1"` + LibraryIDs []ID `query:"library_ids,explode" maxItems:"200" doc:"Restrict to several libraries, one library_ids parameter per id; combines with library_id. Libraries the viewer cannot see are dropped, and a scope left with none is empty. Not accepted with source=section" example:"[\"1\",\"2\"]"` CollectionID string `query:"collection_id"` PersonID ID `query:"person_id"` Type string `query:"type" doc:"Media scope, as on listCatalogItems; video_with_episodes lists the facets of video" example:"movie"` @@ -72,8 +74,8 @@ type CatalogFiltersInput struct { type CatalogFacetSearchInput struct { CatalogFiltersInput Facet string `query:"facet" required:"true" enum:"genre,studio,network,country,original_language,content_rating,author,narrator,series" doc:"The facet to search" example:"author"` - Q string `query:"q" doc:"Case-insensitive prefix" example:"ste"` - Limit int `query:"limit" minimum:"1" maximum:"100" default:"20" doc:"Most matches to return; default 20, maximum 100"` + Q string `query:"q" doc:"Case-insensitive search text; see matches and values for how each answers it" example:"ste"` + Limit int `query:"limit" minimum:"1" maximum:"100" default:"20" doc:"Most values to return in matches and in values; default 20, maximum 100"` } // AudiobookGroupsInput is the listAudiobookGroups query. @@ -235,10 +237,20 @@ type CatalogFiltersOutput struct { Body CatalogFilters } -// CatalogFacetMatches is a facet typeahead answer. +// CatalogFacetMatches is a facet typeahead answer. matches is the prefix +// answer every client has read; values is the ranked answer with counts. type CatalogFacetMatches struct { - Matches []string `json:"matches" doc:"Empty, never null"` - HasMore bool `json:"has_more" doc:"Whether more values matched than limit"` + Matches []string `json:"matches" doc:"Values that start with q, case-insensitively, A-Z; empty for an empty q. Empty, never null"` + HasMore bool `json:"has_more" doc:"Whether more values than limit start with q"` + Values []CatalogFacetValue `json:"values" doc:"The ranked answer, each value with its title count. For genre, studio, network, country, original_language and content_rating a value matches when it or any word in it starts with q; whole-value matches rank first, then more titles, then A-Z, and an empty q returns the most common values. For author, narrator and series these are the names in matches. Empty, never null"` + ValuesHasMore bool `json:"values_has_more" doc:"Whether more values matched than limit for values"` +} + +// CatalogFacetValue is one facet value with the number of titles in the +// scope that carry it. +type CatalogFacetValue struct { + Value string `json:"value" example:"Warner Bros. Pictures"` + Count int `json:"count" doc:"Titles in the scope with this value; values can lag catalog changes by up to two minutes" example:"42"` } // CatalogFacetMatchesOutput is the searchCatalogFacet response. @@ -586,7 +598,7 @@ func registerCatalogItems(reg *Registry) { Register(reg, viewerOperation(humaOp(http.MethodGet, Prefix+"/catalog/filters", "getCatalogFilters", "catalog", "The facet values available in a scope, for filter menus.")), reg.getCatalogFilters) Register(reg, viewerOperation(humaOp(http.MethodGet, Prefix+"/catalog/filters/search", "searchCatalogFacet", "catalog", - "Prefix typeahead over one facet of a scope.")), reg.searchCatalogFacet) + "Typeahead over one facet of a scope, with title counts.")), reg.searchCatalogFacet) query := humaOp(http.MethodPost, Prefix+"/catalog/query", opQueryCatalogItems, "catalog", "Page the catalog by a JSON rule-group query; the body form of the browse.") query.DefaultStatus = http.StatusOK @@ -849,6 +861,32 @@ func (in *CatalogFiltersInput) catalogValues() url.Values { return v } +// catalogRequest parses the scope and adds library_ids to the libraries it +// names, sorted and without repeats, so one set of libraries shares one +// cached facet value list in whatever order a client sends it. The resolver +// intersects them with the viewer's libraries, so they can narrow a scope +// but never widen it. +func (in *CatalogFiltersInput) catalogRequest() (catalogpkg.CatalogRequest, *Problem) { + req, p := parseCatalogRequest(in.catalogValues()) + if p != nil || len(in.LibraryIDs) == 0 { + return req, p + } + if req.Source == catalogpkg.CatalogSourceSection { + return catalogpkg.CatalogRequest{}, NewProblem(TypeValidationFailed, "The request did not pass validation; see errors."). + WithErrors(ProblemError{Location: "query.library_ids", Code: codeInvalid, Detail: "library_ids does not apply to source=section"}) + } + for _, id := range in.LibraryIDs { + n, p := id.positive("query.library_ids") + if p != nil { + return catalogpkg.CatalogRequest{}, p + } + req.Query.LibraryIDs = append(req.Query.LibraryIDs, n) + } + slices.Sort(req.Query.LibraryIDs) + req.Query.LibraryIDs = slices.Compact(req.Query.LibraryIDs) + return req, nil +} + // parseCatalogRequest runs the shared parser and reports its refusal as a // 422 on the query parameter the message names; the source when it names // none, since the source decides what the rest must carry. @@ -1049,7 +1087,7 @@ func (reg *Registry) getCatalogFilters(ctx context.Context, in *CatalogFiltersIn if p != nil { return nil, p } - req, p := parseCatalogRequest(in.catalogValues()) + req, p := in.catalogRequest() if p != nil { return nil, p } @@ -1077,7 +1115,7 @@ func (reg *Registry) searchCatalogFacet(ctx context.Context, in *CatalogFacetSea if p != nil { return nil, p } - req, p := parseCatalogRequest(in.catalogValues()) + req, p := in.catalogRequest() if p != nil { return nil, p } @@ -1085,7 +1123,14 @@ func (reg *Registry) searchCatalogFacet(ctx context.Context, in *CatalogFacetSea if err != nil { return nil, catalogProblem(err, "query.facet") } - return &CatalogFacetMatchesOutput{Body: CatalogFacetMatches{Matches: NonNil(view.Matches), HasMore: view.HasMore}}, nil + values := make([]CatalogFacetValue, len(view.Values)) + for i, v := range view.Values { + values[i] = CatalogFacetValue{Value: v.Value, Count: v.Count} + } + return &CatalogFacetMatchesOutput{Body: CatalogFacetMatches{ + Matches: NonNil(view.Matches), HasMore: view.HasMore, + Values: values, ValuesHasMore: view.ValuesHasMore, + }}, nil } func (reg *Registry) queryCatalogItems(ctx context.Context, cursors *Cursors, in *CatalogQueryInput) (*CatalogBrowseOutput, error) { diff --git a/internal/apiv2/catalog_items_test.go b/internal/apiv2/catalog_items_test.go index 151ddcf7ed..3256587d34 100644 --- a/internal/apiv2/catalog_items_test.go +++ b/internal/apiv2/catalog_items_test.go @@ -70,14 +70,21 @@ func (f *fakeCatalog) Filters(_ context.Context, _ handlers.ItemViewer, req cata return view, nil } -func (f *fakeCatalog) SearchFacet(_ context.Context, _ handlers.ItemViewer, _ catalogpkg.CatalogRequest, facet, prefix string, limit int) (handlers.CatalogFacetSearchView, error) { +func (f *fakeCatalog) SearchFacet(_ context.Context, _ handlers.ItemViewer, req catalogpkg.CatalogRequest, facet, q string, limit int) (handlers.CatalogFacetSearchView, error) { if f.err != nil { return handlers.CatalogFacetSearchView{}, f.err } - if facet == "author" && strings.HasPrefix("frank herbert", prefix) { - return handlers.CatalogFacetSearchView{Matches: []string{"Frank Herbert"}, HasMore: limit == 1}, nil + f.lastReq = req + if facet == "author" && strings.HasPrefix("frank herbert", q) { + return handlers.CatalogFacetSearchView{ + Matches: []string{"Frank Herbert"}, HasMore: limit == 1, + Values: []catalogpkg.FacetValue{{Value: "Frank Herbert", Count: 6}}, ValuesHasMore: limit == 1, + }, nil + } + if facet == "genre" && q == "" { + return handlers.CatalogFacetSearchView{Values: []catalogpkg.FacetValue{{Value: "Drama", Count: 9}}, ValuesHasMore: true}, nil } - return handlers.CatalogFacetSearchView{Matches: []string{}}, nil + return handlers.CatalogFacetSearchView{}, nil } func (f *fakeCatalog) AudiobookGroups(_ context.Context, v handlers.ItemViewer, q catalogpkg.AudiobookGroupsQuery) (handlers.AudiobookGroupsView, error) { @@ -431,13 +438,32 @@ func TestCatalogFiltersAndFacetSearch(t *testing.T) { requireProblem(t, do(t, h, http.MethodGet, "/api/v2/catalog/filters", "", bearer(memberToken)), TypeValidationFailed) rec = do(t, h, http.MethodGet, "/api/v2/catalog/filters/search?facet=author&q=fra&limit=1", "", viewerHeaders()) - if rec.Code != 200 || rec.Body.String() != `{"matches":["Frank Herbert"],"has_more":true}`+"\n" { + if rec.Code != 200 || rec.Body.String() != `{"matches":["Frank Herbert"],"has_more":true,"values":[{"value":"Frank Herbert","count":6}],"values_has_more":true}`+"\n" { t.Fatal(rec.Code, rec.Body.String()) } rec = do(t, h, http.MethodGet, "/api/v2/catalog/filters/search?facet=narrator&q=zz", "", viewerHeaders()) - if rec.Code != 200 || !strings.Contains(rec.Body.String(), `"matches":[]`) { + if rec.Code != 200 || rec.Body.String() != `{"matches":[],"has_more":false,"values":[],"values_has_more":false}`+"\n" { + t.Fatal(rec.Code, rec.Body.String()) + } + // An empty q: no prefix matches, while values ranks the common ones. + rec = do(t, h, http.MethodGet, "/api/v2/catalog/filters/search?facet=genre", "", viewerHeaders()) + if rec.Code != 200 || rec.Body.String() != `{"matches":[],"has_more":false,"values":[{"value":"Drama","count":9}],"values_has_more":true}`+"\n" { t.Fatal(rec.Code, rec.Body.String()) } + rec = do(t, h, http.MethodGet, "/api/v2/catalog/filters/search?facet=studio&library_id=3&library_ids=1&library_ids=3&library_ids=2&type=series", "", viewerHeaders()) + if rec.Code != 200 || !reflect.DeepEqual(fake.lastReq.Query.LibraryIDs, []int{1, 2, 3}) || fake.lastReq.Query.MediaScope != "series" { + t.Fatalf("library_ids: %d %s seam request %+v", rec.Code, rec.Body.String(), fake.lastReq.Query) + } + rec = do(t, h, http.MethodGet, "/api/v2/catalog/filters?library_ids=2", "", viewerHeaders()) + if rec.Code != 200 || !reflect.DeepEqual(fake.lastReq.Query.LibraryIDs, []int{2}) { + t.Fatalf("filters library_ids: %d seam request %+v", rec.Code, fake.lastReq.Query) + } + for _, query := range []string{"library_ids=0", "library_ids=x", "library_ids=01", "source=section§ion_id=s1&library_id=1&library_ids=2"} { + p := requireProblem(t, do(t, h, http.MethodGet, "/api/v2/catalog/filters/search?facet=studio&"+query, "", viewerHeaders()), TypeValidationFailed) + if len(p.Errors) != 1 || p.Errors[0].Location != "query.library_ids" { + t.Fatalf("%s: errors = %+v", query, p.Errors) + } + } requireProblem(t, do(t, h, http.MethodGet, "/api/v2/catalog/filters/search?q=fra", "", viewerHeaders()), TypeValidationFailed) requireProblem(t, do(t, h, http.MethodGet, "/api/v2/catalog/filters/search?facet=color", "", viewerHeaders()), TypeValidationFailed) requireProblem(t, do(t, h, http.MethodGet, "/api/v2/catalog/filters/search?facet=author&limit=500", "", viewerHeaders()), TypeValidationFailed) diff --git a/internal/apiv2/catalog_search_capabilities.go b/internal/apiv2/catalog_search_capabilities.go index 057a449bb7..c0bb7f1d07 100644 --- a/internal/apiv2/catalog_search_capabilities.go +++ b/internal/apiv2/catalog_search_capabilities.go @@ -11,6 +11,7 @@ import ( type CatalogSearchCapabilities struct { Capability PeopleMediaScope bool `json:"people_media_scope,omitzero" doc:"People search accepts media_scope and filters credits by viewer access"` + FacetValueSearch bool `json:"facet_value_search,omitzero" doc:"searchCatalogFacet accepts library_ids and answers values and values_has_more: ranked values with title counts that match word starts, and the most common values for an empty q"` PersonPrefetch bool `json:"person_prefetch,omitzero" doc:"Person reads accept prefetch=true for speculative reads that do not queue a provider refresh"` VideoWithEpisodes bool `json:"video_with_episodes_scope,omitzero" doc:"listCatalogItems and queryCatalogItems accept type=video_with_episodes on the query source (text search over movies, series, and episodes), and listPeople accepts it as media_scope"` Provider string `json:"provider,omitempty" enum:"postgres,meilisearch"` @@ -45,6 +46,7 @@ func registerCatalogSearchCapabilities(reg *Registry) { Capability: Capability{State: StateAvailable}, Provider: result.Provider, PeopleMediaScope: reg.deps.People != nil && reg.deps.CatalogAccess != nil, PersonPrefetch: reg.deps.People != nil, + FacetValueSearch: true, VideoWithEpisodes: true, ResultWindowLimit: result.ResultWindowLimit, SessionTTLSeconds: result.SessionTTLSeconds, MaxSessionsPerAccount: result.MaxSessionsPerAccount, }}, nil diff --git a/internal/apiv2/catalog_search_capabilities_test.go b/internal/apiv2/catalog_search_capabilities_test.go index e07b849afc..6e10065a14 100644 --- a/internal/apiv2/catalog_search_capabilities_test.go +++ b/internal/apiv2/catalog_search_capabilities_test.go @@ -26,6 +26,9 @@ func TestCatalogSearchCapabilities(t *testing.T) { if !strings.Contains(rec.Body.String(), `"person_prefetch":true`) { t.Fatal("person prefetch reads are not advertised", rec.Body.String()) } + if !strings.Contains(rec.Body.String(), `"facet_value_search":true`) { + t.Fatal("facet value search is not advertised", rec.Body.String()) + } if !strings.Contains(rec.Body.String(), `"video_with_episodes_scope":true`) { t.Fatal("the video_with_episodes search scope is not advertised", rec.Body.String()) } diff --git a/internal/apiv2/collection_capabilities_sync_test.go b/internal/apiv2/collection_capabilities_sync_test.go new file mode 100644 index 0000000000..d858fa1891 --- /dev/null +++ b/internal/apiv2/collection_capabilities_sync_test.go @@ -0,0 +1,182 @@ +package apiv2 + +import ( + "context" + "encoding/json" + "net/http" + "os" + "regexp" + "testing" + "time" + + "github.com/Silo-Server/silo-server/internal/userstore" +) + +const adminCollectionCapabilitiesPath = "/api/v2/admin/collections/capabilities" + +func (f *fakeAdminCollections) AdminCollectionFeatures(context.Context) userstore.CollectionFeatures { + f.featureReads++ + return userstore.CollectionFeatures{Groups: true, Imports: true, Artwork: true, ItemReorder: true} +} + +func (f *fakeCollectionImports) MDBListConfigured() bool { return f.configured } + +type syncCapabilities struct { + MDBListSearch *bool `json:"mdblist_search"` + ScheduleTimeZone *struct { + UTCOffset string `json:"utc_offset"` + Abbreviation string `json:"abbreviation"` + Name *string `json:"name"` + } `json:"schedule_time_zone"` +} + +func readSyncCapabilities(t *testing.T, h http.Handler, path string, headers map[string]string) syncCapabilities { + t.Helper() + rec := do(t, h, http.MethodGet, path, "", headers) + if rec.Code != http.StatusOK { + t.Fatalf("%s: %d %s", path, rec.Code, rec.Body) + } + var got syncCapabilities + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.MDBListSearch == nil || got.ScheduleTimeZone == nil { + t.Fatalf("%s: missing mdblist_search or schedule_time_zone in %s", path, rec.Body) + } + return got +} + +// TestCollectionCapabilitiesReportMDBListSearch checks that both capability +// documents report mdblist_search from the MDBList client the search route +// uses, and report false when no import service is wired. +func TestCollectionCapabilitiesReportMDBListSearch(t *testing.T) { + for _, tc := range []struct { + name string + imports CollectionImportService + want bool + }{ + {name: "configured", imports: &fakeCollectionImports{configured: true}, want: true}, + {name: "no api key", imports: &fakeCollectionImports{configured: false}, want: false}, + {name: "no import service", imports: nil, want: false}, + } { + t.Run(tc.name, func(t *testing.T) { + deps, _, _ := collectionDeps(t) + deps.CollectionImports = tc.imports + deps.AdminCollections = newFakeAdminCollections() + h := newTestHandler(t, deps) + if got := *readSyncCapabilities(t, h, "/api/v2/collections/capabilities", viewerHeaders()).MDBListSearch; got != tc.want { + t.Errorf("personal mdblist_search = %v, want %v", got, tc.want) + } + if got := *readSyncCapabilities(t, h, adminCollectionCapabilitiesPath, bearer(adminToken)).MDBListSearch; got != tc.want { + t.Errorf("admin mdblist_search = %v, want %v", got, tc.want) + } + }) + } +} + +// TestCollectionCapabilitiesReportScheduleTimeZone checks that both documents +// report the answering node's current UTC offset and abbreviation, and leave +// out the zone name when the process has no TZ setting. +func TestCollectionCapabilitiesReportScheduleTimeZone(t *testing.T) { + deps, _, _ := collectionDeps(t) + deps.AdminCollections = newFakeAdminCollections() + h := newTestHandler(t, deps) + offsetPattern := regexp.MustCompile(`^[+-][0-9]{2}:[0-9]{2}$`) + for _, read := range []struct { + path string + headers map[string]string + }{ + {"/api/v2/collections/capabilities", viewerHeaders()}, + {adminCollectionCapabilitiesPath, bearer(adminToken)}, + } { + zone := readSyncCapabilities(t, h, read.path, read.headers).ScheduleTimeZone + now := time.Now() + abbreviation, seconds := now.Zone() + if !offsetPattern.MatchString(zone.UTCOffset) { + t.Fatalf("%s: utc_offset %q is not ±hh:mm", read.path, zone.UTCOffset) + } + parsed, err := time.Parse("-07:00", zone.UTCOffset) + if err != nil { + t.Fatalf("%s: utc_offset %q: %v", read.path, zone.UTCOffset, err) + } + if _, got := parsed.Zone(); got != seconds { + t.Errorf("%s: utc_offset %q is %ds, want %ds", read.path, zone.UTCOffset, got, seconds) + } + if zone.Abbreviation != abbreviation { + t.Errorf("%s: abbreviation %q, want %q", read.path, zone.Abbreviation, abbreviation) + } + if _, set := os.LookupEnv("TZ"); !set && zone.Name != nil { + t.Errorf("%s: name %q reported without a TZ setting", read.path, *zone.Name) + } + } +} + +// TestScheduleTimeZoneAt pins the offset and abbreviation across daylight +// saving time, and the IANA name only when the local zone has one. +func TestScheduleTimeZoneAt(t *testing.T) { + chicago, err := time.LoadLocation("America/Chicago") + if err != nil { + t.Skipf("time zone database unavailable: %v", err) + } + summer := time.Date(2026, time.July, 1, 12, 0, 0, 0, chicago) + winter := time.Date(2026, time.January, 1, 12, 0, 0, 0, chicago) + if got, want := scheduleTimeZoneAt(summer, ianaZoneName("America/Chicago")), (CollectionScheduleTimeZone{UTCOffset: "-05:00", Abbreviation: "CDT", Name: "America/Chicago"}); got != want { + t.Errorf("summer = %+v, want %+v", got, want) + } + if got, want := scheduleTimeZoneAt(winter, ""), (CollectionScheduleTimeZone{UTCOffset: "-06:00", Abbreviation: "CST"}); got != want { + t.Errorf("winter without TZ = %+v, want %+v", got, want) + } + if got, want := scheduleTimeZoneAt(time.Date(2026, time.July, 1, 12, 0, 0, 0, time.UTC), ""), (CollectionScheduleTimeZone{UTCOffset: "+00:00", Abbreviation: "UTC"}); got != want { + t.Errorf("utc = %+v, want %+v", got, want) + } + kolkata := time.FixedZone("IST", 5*3600+30*60) + if got := scheduleTimeZoneAt(time.Date(2026, time.July, 1, 12, 0, 0, 0, kolkata), "").UTCOffset; got != "+05:30" { + t.Errorf("half-hour offset = %q, want +05:30", got) + } + + // Names as Go assigns them to time.Local: the TZ value when it names a + // zone, "UTC" when loading TZ fails, "Local" for /etc/localtime, and the + // path for any other TZ file. + for name, want := range map[string]string{ + "America/Chicago": "America/Chicago", + "UTC": "UTC", + "Local": "", + "/usr/share/zoneinfo/Europe/Paris": "", + "": "", + } { + if got := ianaZoneName(name); got != want { + t.Errorf("ianaZoneName(%q) = %q, want %q", name, got, want) + } + } +} + +// TestScheduleTimeZoneNamesTheZoneGoLoaded checks that the reported name +// comes from the zone the offset was read from, so the two cannot disagree. +func TestScheduleTimeZoneNamesTheZoneGoLoaded(t *testing.T) { + tokyo, err := time.LoadLocation("Asia/Tokyo") + if err != nil { + t.Skipf("time zone database unavailable: %v", err) + } + saved := time.Local + time.Local = tokyo + t.Cleanup(func() { time.Local = saved }) + + want := CollectionScheduleTimeZone{UTCOffset: "+09:00", Abbreviation: "JST", Name: "Asia/Tokyo"} + if got := (&Registry{}).scheduleTimeZone(); got != want { + t.Errorf("scheduleTimeZone() = %+v, want %+v", got, want) + } +} + +// TestAdminCollectionCapabilitiesRequireActingAdmin restates #193 S5 for the +// administrator capability document: a regular account and a non-primary +// profile on an admin account are refused before any feature is read. +func TestAdminCollectionCapabilitiesRequireActingAdmin(t *testing.T) { + f := newFakeAdminCollections() + h := adminCollectionsTestHandler(t, f) + for _, headers := range []map[string]string{bearer(memberToken), with(bearer(adminToken), "X-Profile-Id", "p-owner")} { + requireProblem(t, do(t, h, http.MethodGet, adminCollectionCapabilitiesPath, "", headers), TypePermissionDenied) + } + if f.featureReads != 0 { + t.Fatal("refused request read the collection features") + } +} diff --git a/internal/apiv2/collection_schedule_zone.go b/internal/apiv2/collection_schedule_zone.go new file mode 100644 index 0000000000..ecfa293e23 --- /dev/null +++ b/internal/apiv2/collection_schedule_zone.go @@ -0,0 +1,47 @@ +package apiv2 + +import ( + "strings" + "time" +) + +// CollectionScheduleTimeZone is the time zone the answering node runs cron +// collection schedules in. Each node reads its own local zone, so the value +// describes only the node that answered. +type CollectionScheduleTimeZone struct { + UTCOffset string `json:"utc_offset" pattern:"^[+-][0-9]{2}:[0-9]{2}$" doc:"Current offset from UTC, daylight saving time included" example:"-05:00"` + Abbreviation string `json:"abbreviation" doc:"Current zone abbreviation as the node's time zone database reports it; some zones report a numeric form such as -03" example:"CDT"` + Name string `json:"name,omitempty" doc:"IANA zone name, from the node's TZ environment variable or UTC when TZ is empty or names no known zone; omitted when the node uses its system default zone or a TZ file path" example:"America/Chicago"` +} + +// scheduleTimeZone reports the zone cron schedules use on this node now. +// Collection sync schedules evaluate cron with time.Now() in time.Local. +func (reg *Registry) scheduleTimeZone() CollectionScheduleTimeZone { + if reg.deps.ScheduleZone != nil { + return reg.deps.ScheduleZone() + } + return scheduleTimeZoneAt(time.Now(), ianaZoneName(time.Local.String())) +} + +// scheduleTimeZoneAt describes now's zone, with name as the IANA name when +// one is known. +func scheduleTimeZoneAt(now time.Time, name string) CollectionScheduleTimeZone { + abbreviation, _ := now.Zone() + return CollectionScheduleTimeZone{UTCOffset: now.Format("-07:00"), Abbreviation: abbreviation, Name: name} +} + +// ianaZoneName returns the IANA name of a time.Local location name, or "". +// Go names time.Local after the TZ value it loaded, "UTC" when loading TZ +// failed, "Local" for the system default, or the file path for a TZ path. +func ianaZoneName(name string) string { + if name == "" || name == "Local" || strings.HasPrefix(name, "/") { + return "" + } + return name +} + +// mdblistSearch reports whether the MDBList search the collection editors use +// can answer: an import service is wired and has an MDBList API key. +func (reg *Registry) mdblistSearch() bool { + return reg.deps.CollectionImports != nil && reg.deps.CollectionImports.MDBListConfigured() +} diff --git a/internal/apiv2/document.go b/internal/apiv2/document.go index 4859a1c9f3..bf34e9bf35 100644 --- a/internal/apiv2/document.go +++ b/internal/apiv2/document.go @@ -640,7 +640,6 @@ func registerAll(reg *Registry) { registerAdminJellyfinCompatStatus(reg) registerAdminJellyfinCompatSettings(reg) - registerAdminSectionSettings(reg) registerAdminAutoscanSources(reg) registerAdminAutoscanConnections(reg) registerAdminAutoscanAvailableSources(reg) diff --git a/internal/apiv2/fixtures_test.go b/internal/apiv2/fixtures_test.go index b60ab79842..871bb9ea6c 100644 --- a/internal/apiv2/fixtures_test.go +++ b/internal/apiv2/fixtures_test.go @@ -22,6 +22,7 @@ import ( "github.com/Silo-Server/silo-server/internal/adminjob" "github.com/Silo-Server/silo-server/internal/api/handlers" catalogsvc "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/collections/templates" "github.com/Silo-Server/silo-server/internal/downloads" "github.com/Silo-Server/silo-server/internal/librarymonitor" "github.com/Silo-Server/silo-server/internal/models" @@ -1177,11 +1178,11 @@ func fixtureCases() []fixtureCase { scenario: "Import runs belong to the authenticated account and use bounded keyset paging.", method: http.MethodGet, path: "/api/v2/history-imports/runs", headers: bearer(memberToken), status: http.StatusOK, assertHeaders: []string{"Content-Type", "Cache-Control"}, schema: "#/components/schemas/HistoryImportRunCollection"}, - {name: "list_collections_ok", operationID: "listCollections", scenario: "Visible personal collections and account groups.", method: http.MethodGet, path: "/api/v2/collections", headers: viewer, status: 200, assertHeaders: []string{"Content-Type", "Cache-Control"}, schema: "#/components/schemas/PersonalCollectionCollection"}, + {name: "list_collections_ok", operationID: "listCollections", scenario: "The acting profile's own collections, then another profile's shared collection; groups is always empty.", method: http.MethodGet, path: "/api/v2/collections", headers: viewer, status: 200, assertHeaders: []string{"Content-Type", "Cache-Control"}, schema: "#/components/schemas/PersonalCollectionCollection"}, {name: "get_collection_ok", operationID: "getCollection", scenario: "Canonical collection editor and strong validator.", method: http.MethodGet, path: "/api/v2/collections/c1", headers: viewer, status: 200, assertHeaders: []string{"Content-Type", "ETag"}, schema: "#/components/schemas/PersonalCollection"}, {name: "create_collection_ok", operationID: "createCollection", scenario: "A non-retryable manual collection creation.", method: http.MethodPost, path: "/api/v2/collections", body: `{"name":"Rainy days","collection_type":"manual"}`, headers: viewer, status: 201, assertHeaders: []string{"Content-Type", "Location"}, schema: "#/components/schemas/PersonalCollection"}, {name: "collection_order_precondition_required", operationID: "reorderCollections", scenario: "Ordering needs the validator observed before the edit.", method: http.MethodPut, path: "/api/v2/collections/order", body: `{"ordered_ids":["c1"]}`, headers: viewer, status: 428, assertHeaders: []string{"Content-Type"}, schema: problem}, - {name: "collection_group_stale", operationID: "updateCollectionGroup", scenario: "A stale group edit leaves the resource unchanged and supplies the current validator.", method: http.MethodPatch, path: "/api/v2/collections/groups/g1", body: `{"name":"Winter"}`, headers: with(viewer, "If-Match", `"stale"`), status: 412, assertHeaders: []string{"Content-Type", "ETag"}, schema: problem}, + {name: "collection_group_unsupported", operationID: "updateCollectionGroup", scenario: "Personal collection groups are no longer supported; every group operation answers capability_unsupported.", method: http.MethodPatch, path: "/api/v2/collections/groups/g1", body: `{"name":"Winter"}`, headers: with(viewer, "If-Match", "*"), status: 501, assertHeaders: []string{"Content-Type"}, schema: problem}, {name: "get_collection_items_ok", operationID: "getCollectionItems", scenario: "A bounded manual membership page with its continuation envelope.", method: http.MethodGet, path: "/api/v2/collections/c1/items", headers: viewer, status: 200, assertHeaders: []string{"Content-Type"}, schema: "#/components/schemas/CollectionPersonalCollectionItem"}, {name: "get_library_job_ok", operationID: "getLibraryJob", scenario: "An administrator polls a queued refresh with a deterministic whole-body validator.", method: http.MethodGet, path: "/api/v2/library-jobs/job-2", headers: bearer(adminToken), status: http.StatusOK, assertHeaders: []string{"Content-Type", "Cache-Control", "ETag", "Retry-After"}, schema: "#/components/schemas/AdminJob"}, @@ -1744,9 +1745,85 @@ func fixtureCases() []fixtureCase { cases = append(cases, deviceSignInFixtureCases()...) cases = append(cases, externalSignInFixtureCases()...) cases = append(cases, adminDownloadPreparationControlFixtureCases()...) + cases = append(cases, fixtureCase{name: "collection_order_foreign_id", operationID: "reorderCollections", scenario: "An order naming another profile's collection, even a shared one, is a validation failure at ordered_ids.", method: http.MethodPut, path: "/api/v2/collections/order", body: `{"ordered_ids":["c2","c1"]}`, headers: with(viewer, "If-Match", "*"), status: 422, assertHeaders: []string{"Content-Type"}, schema: problem}) + cases = append(cases, personalCollectionCreateFixtureCases()...) + cases = append(cases, personalCollectionPreviewFixtureCases()...) + cases = append(cases, adminTemplateBundleFixtureCases()...) + cases = append(cases, adminCollectionRowFixtureCases()...) + cases = append(cases, adminCollectionCapabilityFixtureCases()...) + cases = append(cases, personalCollectionContainsFixtureCases()...) return append(cases, loginSessionFixtureCases()...) } +// personalCollectionContainsFixtureCases pin the collection list marked for +// one title, as Add to collection reads it. +func personalCollectionContainsFixtureCases() []fixtureCase { + return []fixtureCase{ + {name: "list_collections_contains_item_ok", operationID: "listCollections", scenario: "Visible personal collections, each of the profile's own manual collections marked with whether it holds the title.", method: http.MethodGet, path: "/api/v2/collections?contains_item=movie-1", headers: viewerHeaders(), status: 200, assertHeaders: []string{"Content-Type", "Cache-Control"}, schema: "#/components/schemas/PersonalCollectionCollection"}, + } +} + +// adminCollectionRowFixtureCases pin the rows that show a server collection: +// the counted administrator list and the rows list of one collection. +func adminCollectionRowFixtureCases() []fixtureCase { + return []fixtureCase{ + {name: "list_admin_collections_ok", operationID: "listAdminCollections", scenario: "Administrator collections with the number of Home rows and of all administrator page rows that show each one; a collection no row shows counts zero.", method: http.MethodGet, path: "/api/v2/admin/collections?library_id=1", headers: bearer(adminToken), status: 200, assertHeaders: []string{"Content-Type"}, schema: "#/components/schemas/AdminCollectionList"}, + {name: "list_admin_collection_sections_ok", operationID: "listAdminCollectionSections", scenario: "Rows that show a collection: Home rows first with a null library, including a starter-pack hero row, then library page rows, each with its page's row count.", method: http.MethodGet, path: "/api/v2/admin/collections/c1/sections", headers: bearer(adminToken), status: 200, assertHeaders: []string{"Content-Type"}, schema: "#/components/schemas/CollectionAdminCollectionSection"}, + } +} + +// adminCollectionCapabilityFixtureCases pin the administrator collection +// capability document with MDBList search configured and the fixed fixture +// schedule time zone. +func adminCollectionCapabilityFixtureCases() []fixtureCase { + return []fixtureCase{ + {name: "get_admin_collection_capabilities_ok", operationID: "getAdminCollectionCapabilities", scenario: "Administrator collection features, including MDBList search and the time zone the answering node runs cron schedules in.", method: http.MethodGet, path: "/api/v2/admin/collections/capabilities", headers: bearer(adminToken), status: 200, assertHeaders: []string{"Content-Type", "Cache-Control", "ETag"}, schema: "#/components/schemas/AdminCollectionCapabilityOutputBody"}, + } +} + +// fixtureScheduleTimeZone is a node on Chicago daylight time with TZ set, so +// the capability fixtures do not depend on the generating machine's zone. +func fixtureScheduleTimeZone() CollectionScheduleTimeZone { + return CollectionScheduleTimeZone{UTCOffset: "-05:00", Abbreviation: "CDT", Name: "America/Chicago"} +} + +// adminTemplateBundleFixtureCases pin a bundle list whose bundles carry a +// summary of each template, with the franchise placeholder marked as needing +// setup. +func adminTemplateBundleFixtureCases() []fixtureCase { + return []fixtureCase{ + {name: "list_admin_collection_template_bundles_ok", operationID: "listAdminCollectionTemplateBundles", scenario: "Template bundles with a summary of each template in bundle order; a template that cannot sync until an administrator sets its source needs setup.", method: http.MethodGet, path: "/api/v2/admin/collections/template-bundles", headers: bearer(adminToken), status: 200, assertHeaders: []string{"Content-Type"}, schema: "#/components/schemas/BundleCatalog"}, + } +} + +// fixtureTemplateBundles is a two-template franchise bundle: one curated +// franchise with a poster and the placeholder an administrator completes. +func fixtureTemplateBundles() []templates.BundleWithTemplates { + registry := templates.NewRegistry() + registry.Register(templates.Template{ID: "tmdb_franchise_star_wars", Title: "Star Wars", Category: templates.CategoryEditorial, Source: templates.SourceTMDBCollection, MediaKind: templates.MediaMovie, Featured: true, PosterPath: "/images/collection-templates/tmdb_franchise_star_wars.jpg", TMDBCollection: &templates.TMDBCollectionSpec{CollectionID: 10}}) + registry.Register(templates.Template{ID: "tmdb_franchise_placeholder", Title: "TMDB Franchise", Category: templates.CategoryEditorial, Source: templates.SourceTMDBCollection, MediaKind: templates.MediaMovie, TMDBCollection: &templates.TMDBCollectionSpec{}}) + registry.RegisterBundle(templates.Bundle{ID: "franchise_collections", Title: "Franchise Collections", Description: "TMDB franchise and saga collections.", TemplateIDs: []string{"tmdb_franchise_star_wars", "tmdb_franchise_placeholder"}}) + return registry.BundlesWithTemplates() +} + +// personalCollectionPreviewFixtureCases pin a smart preview whose items carry +// a poster URL when they have a poster and omit it otherwise. +func personalCollectionPreviewFixtureCases() []fixtureCase { + return []fixtureCase{ + {name: "preview_collection_ok", operationID: "previewCollection", scenario: "A smart query's first matches within the acting profile's access, each with its poster when it has one.", method: http.MethodPost, path: "/api/v2/collections/preview", body: `{"query_definition":{"match":"all","groups":[]},"limit":2}`, headers: viewerHeaders(), status: 200, assertHeaders: []string{"Content-Type"}, schema: "#/components/schemas/PersonalCollectionPreviewOutputBody"}, + } +} + +// personalCollectionCreateFixtureCases pin a create that carries a +// description and the capability that advertises it. +func personalCollectionCreateFixtureCases() []fixtureCase { + viewer := viewerHeaders() + return []fixtureCase{ + {name: "create_collection_with_description_ok", operationID: "createCollection", scenario: "A manual collection created with its description.", method: http.MethodPost, path: "/api/v2/collections", body: `{"name":"Rainy days","description":"For wet afternoons","collection_type":"manual"}`, headers: viewer, status: 201, assertHeaders: []string{"Content-Type", "Location"}, schema: "#/components/schemas/PersonalCollection"}, + {name: "get_collection_capabilities_ok", operationID: "getCollectionCapabilities", scenario: "Personal collection features, including a description on create, MDBList search, the time zone the answering node runs cron schedules in, and preview posters.", method: http.MethodGet, path: "/api/v2/collections/capabilities", headers: viewer, status: 200, assertHeaders: []string{"Content-Type", "Cache-Control", "ETag"}, schema: "#/components/schemas/CollectionCapabilities"}, + } +} + func loginSessionFixtureCases() []fixtureCase { problem := "#/components/schemas/Problem" return []fixtureCase{ @@ -1835,7 +1912,9 @@ func fixtureDeps() Dependencies { deps.EbookConfig = &fakeEbookConfig{} deps.EbookAnnotations = &fakeEbookAnnotations{} deps.ProgressBootstrap = &fakeBootstrap{} - deps.PersonalCollections = &fixturePersonalCollections{fakePersonalCollections: fakePersonalCollections{list: handlers.PersonalCollectionListView{Collections: []handlers.PersonalCollectionView{fixtureCollectionView()}, Groups: []handlers.CollectionGroupView{}}}} + sharedCollection := fixtureCollectionView() + sharedCollection.ID, sharedCollection.ProfileID, sharedCollection.CreatorProfileID, sharedCollection.Name, sharedCollection.IsShared = "c2", "p-primary", "p-primary", "Family night", true + deps.PersonalCollections = &fixturePersonalCollections{fakePersonalCollections: fakePersonalCollections{list: handlers.PersonalCollectionListView{Collections: []handlers.PersonalCollectionView{fixtureCollectionView(), sharedCollection}, Groups: []handlers.CollectionGroupView{}}, holding: map[string]bool{"c1": true}, features: userstore.CollectionFeatures{Description: true}}, previewCollections: previewCollections{view: fixturePreviewView()}} deps.CollectionImports = &fakeCollectionImports{configured: true} deps, _ = withLibraryAdmin(deps) deps.LibraryMonitoring = &fakeLibraryMonitoring{snap: librarymonitor.StatusSnapshot{ @@ -1852,6 +1931,10 @@ func fixtureDeps() Dependencies { adminCollections.view.SortConfig = json.RawMessage(`{}`) adminCollections.view.SourceConfig = json.RawMessage(`{}`) adminCollections.job = &models.AdminJob{ID: "collection-job", JobType: adminjob.JobTypeTemplateBundleApply, Status: adminjob.StatusQueued, RequestedAt: fixedTime()} + adminCollections.bundles = fixtureTemplateBundles() + rows := fakeRowReferences() + adminCollections.listed, adminCollections.sections, adminCollections.rowCounts = rows.listed, rows.sections, rows.rowCounts + deps.ScheduleZone = fixtureScheduleTimeZone deps.AdminCollections = adminCollections deps.AdminSections = newFakeAdminSections() adminPolicy := newFakeAdminPolicy() @@ -2147,7 +2230,10 @@ func TestContractFixtures(t *testing.T) { } } -type fixturePersonalCollections struct{ fakePersonalCollections } +type fixturePersonalCollections struct { + fakePersonalCollections + previewCollections +} func (f *fixturePersonalCollections) PersonalCollectionItemsPage(context.Context, int, string, string, catalogsvc.AccessFilter, userstore.CollectionItemsPageOptions, *catalogsvc.QueryCursor) (handlers.PersonalCollectionPageView, error) { return handlers.PersonalCollectionPageView{Items: []handlers.PersonalCollectionItemView{{CollectionID: "c1", MediaItemID: "movie:heat-1995", Position: 0, AddedAt: "2026-01-02T03:04:05.000Z"}}, Revision: 1}, nil diff --git a/internal/apiv2/home.go b/internal/apiv2/home.go index ed93661c99..33aa025b05 100644 --- a/internal/apiv2/home.go +++ b/internal/apiv2/home.go @@ -109,7 +109,7 @@ type RecipeDefinition struct { Presets []RecipePreset `json:"presets" doc:"Empty, never null"` AvoidDuplicates bool `json:"avoid_duplicates" example:"true"` SupportsRotation bool `json:"supports_rotation" example:"false"` - AdminOnly bool `json:"admin_only" example:"false"` + AdminOnly bool `json:"admin_only" doc:"Only an admin may add a new section of this recipe; a profile keeps and changes the ones it already has" example:"false"` } // RecipeCategory is one gallery category with its recipes. diff --git a/internal/apiv2/library_views.go b/internal/apiv2/library_views.go index 3ab8b854a3..5133d7838c 100644 --- a/internal/apiv2/library_views.go +++ b/internal/apiv2/library_views.go @@ -114,6 +114,7 @@ type CuratedCollection struct { ItemCount int `json:"item_count" example:"12"` CreatedAt Instant `json:"created_at" example:"2026-01-02T03:04:05.678Z"` UpdatedAt Instant `json:"updated_at" example:"2026-01-02T03:04:05.678Z"` + PosterIsCollage bool `json:"poster_is_collage" doc:"poster_url is the acting profile's collage of the collection's first titles it can see, composed by the server because the collection has no uploaded, template or imported poster. False for an uploaded, template or imported poster and whenever poster_url is empty. See getCollectionCapabilities poster_collages" example:"false"` } // LibraryCollectionCard is a collection as the Collections tab shows it. @@ -125,6 +126,7 @@ type LibraryCollectionCard struct { ItemCount int `json:"item_count" example:"12"` Featured bool `json:"featured,omitempty"` CreatorProfileID *string `json:"creator_profile_id,omitempty" doc:"Present on a personal collection: the profile that made it"` + PosterIsCollage bool `json:"poster_is_collage" doc:"poster_url is the acting profile's collage of the collection's first titles it can see, composed by the server because the collection has no uploaded, template or imported poster. False for an uploaded, template or imported poster and whenever poster_url is empty. See getCollectionCapabilities poster_collages" example:"false"` } // LibraryCollectionGroup is one group of the Collections tab. @@ -166,6 +168,7 @@ type UserCollection struct { ItemCount int `json:"item_count" example:"4"` PosterURL string `json:"poster_url,omitempty" doc:"Presigned, short-lived"` PosterThumbhash string `json:"poster_thumbhash,omitempty"` + PosterIsCollage bool `json:"poster_is_collage" doc:"poster_url is the acting profile's collage of the collection's first titles it can see, composed by the server because the collection has no uploaded, template or imported poster. False for an uploaded, template or imported poster and whenever poster_url is empty. See getCollectionCapabilities poster_collages" example:"false"` CreatedAt Instant `json:"created_at" example:"2026-01-02T03:04:05.678Z"` UpdatedAt Instant `json:"updated_at" example:"2026-01-02T03:04:05.678Z"` } @@ -343,6 +346,7 @@ func curatedCollectionOf(v handlers.LibraryCollectionView) CuratedCollection { ManagementMode: v.ManagementMode, ManagementSource: v.ManagementSource, ManagementKey: v.ManagementKey, LastSyncStatus: v.LastSyncStatus, LastSyncMessage: v.LastSyncMessage, LastSyncAt: instantOfStamp(v.LastSyncAt), SyncSchedule: v.SyncSchedule, NextSyncAt: instantOfStamp(v.NextSyncAt), ItemCount: v.ItemCount, CreatedAt: created, UpdatedAt: updated, + PosterIsCollage: v.PosterIsCollage && v.PosterURL != "", } } @@ -358,7 +362,8 @@ func jsonValue(raw json.RawMessage) json.RawMessage { func collectionCardsOf(cards []handlers.LibraryCollectionTabEntryView) []LibraryCollectionCard { out := make([]LibraryCollectionCard, 0, len(cards)) for _, c := range cards { - out = append(out, LibraryCollectionCard{ID: c.ID, Title: c.Title, PosterURL: c.PosterURL, PosterThumbhash: c.PosterThumbhash, ItemCount: c.ItemCount, Featured: c.Featured, CreatorProfileID: c.CreatorProfileID}) + out = append(out, LibraryCollectionCard{ID: c.ID, Title: c.Title, PosterURL: c.PosterURL, PosterThumbhash: c.PosterThumbhash, ItemCount: c.ItemCount, Featured: c.Featured, CreatorProfileID: c.CreatorProfileID, + PosterIsCollage: c.PosterIsCollage && c.PosterURL != ""}) } return out } @@ -376,7 +381,8 @@ func (reg *Registry) getLibraryCollections(ctx context.Context, in *LibraryViewI if p != nil { return nil, p } - view, err := svc.LibraryCollectionsTab(ctx, id, userID, profileID) + // Marked as /api/v2, personal collections show their collages. + view, err := svc.LibraryCollectionsTab(handlers.WithNativeAPIV2(ctx), id, userID, profileID) if err != nil { return nil, serviceProblem(err) } @@ -406,7 +412,8 @@ func userCollectionOf(c usercollections.ServerVisibleCollection) UserCollection updated = *t } return UserCollection{ID: c.ID, CreatorProfileID: c.CreatorProfileID, Name: c.Name, Description: c.Description, CollectionType: c.CollectionType, - ItemCount: c.ItemCount, PosterURL: c.PosterURL, PosterThumbhash: c.PosterThumbhash, CreatedAt: created, UpdatedAt: updated} + ItemCount: c.ItemCount, PosterURL: c.PosterURL, PosterThumbhash: c.PosterThumbhash, PosterIsCollage: c.PosterIsCollage && c.PosterURL != "", + CreatedAt: created, UpdatedAt: updated} } func (reg *Registry) listLibraryUserCollections(ctx context.Context, in *LibraryViewInput) (*UserCollectionCollectionOutput, error) { @@ -422,7 +429,8 @@ func (reg *Registry) listLibraryUserCollections(ctx context.Context, in *Library if p != nil { return nil, p } - views, err := svc.LibraryUserCollections(ctx, id, userID, profileID) + // Marked as /api/v2, personal collections show their collages. + views, err := svc.LibraryUserCollections(handlers.WithNativeAPIV2(ctx), id, userID, profileID) if err != nil { return nil, serviceProblem(err) } diff --git a/internal/apiv2/library_views_test.go b/internal/apiv2/library_views_test.go index bb34da2a29..e4d539ab92 100644 --- a/internal/apiv2/library_views_test.go +++ b/internal/apiv2/library_views_test.go @@ -476,3 +476,37 @@ func TestLibraryCollectionItemsUnavailable(t *testing.T) { t.Fatalf("unconfigured route status = %d, want 503", rec.Code) } } + +// Library collection cards and listLibraryUserCollections entries mark a +// collage poster, as getCollectionCapabilities poster_collages promises. +func TestLibraryCollectionCardsMarkCollages(t *testing.T) { + cards := collectionCardsOf([]handlers.LibraryCollectionTabEntryView{ + {ID: "collage", PosterURL: "https://cdn.test/collage.webp", PosterIsCollage: true}, + {ID: "uploaded", PosterURL: "https://cdn.test/uploaded.webp"}, + {ID: "unsigned", PosterIsCollage: true}, + }) + for i, want := range []bool{true, false, false} { + if cards[i].PosterIsCollage != want { + t.Fatalf("card %s poster_is_collage = %v, want %v", cards[i].ID, cards[i].PosterIsCollage, want) + } + } + if got := userCollectionOf(usercollections.ServerVisibleCollection{ID: "collage", PosterURL: "https://cdn.test/collage.webp", PosterIsCollage: true}); !got.PosterIsCollage { + t.Fatalf("user collection = %+v, want poster_is_collage", got) + } + if got := userCollectionOf(usercollections.ServerVisibleCollection{ID: "unsigned", PosterIsCollage: true}); got.PosterIsCollage { + t.Fatalf("user collection without poster_url = %+v, want no poster_is_collage", got) + } + // getLibraryCollections' full collections[] entries mark it too. + for _, tc := range []struct { + view handlers.LibraryCollectionView + want bool + }{ + {handlers.LibraryCollectionView{ID: "collage", PosterURL: "https://cdn.test/collage.webp", PosterIsCollage: true}, true}, + {handlers.LibraryCollectionView{ID: "uploaded", PosterURL: "https://cdn.test/uploaded.webp"}, false}, + {handlers.LibraryCollectionView{ID: "unsigned", PosterIsCollage: true}, false}, + } { + if got := curatedCollectionOf(tc.view); got.PosterIsCollage != tc.want { + t.Fatalf("curated %s poster_is_collage = %v, want %v", tc.view.ID, got.PosterIsCollage, tc.want) + } + } +} diff --git a/internal/apiv2/personal_collections.go b/internal/apiv2/personal_collections.go index 35e2feebd5..0917ccd63a 100644 --- a/internal/apiv2/personal_collections.go +++ b/internal/apiv2/personal_collections.go @@ -15,10 +15,13 @@ import ( "github.com/Silo-Server/silo-server/internal/userstore" ) -// Section personal-collections: a profile's own collections and groups -// (v1 /collections, CollectionHandler and UserCollectionImportHandler). +// Section personal-collections: a profile's own collections and the ones +// other profiles on its login share (v1 /collections, CollectionHandler and +// UserCollectionImportHandler). -// PersonalCollection is a collection the acting profile owns or may see. +// PersonalCollection is a collection the acting profile owns, or another +// profile's collection shared with every profile on the login. Only its +// creator changes it. type PersonalCollection struct { ID ID `json:"id" example:"01J9Z8C3W4R5T6Y7U8I9O0P1Q4"` ProfileID ID `json:"profile_id" doc:"The profile the row is scoped to" example:"p-owner"` @@ -26,15 +29,15 @@ type PersonalCollection struct { Name string `json:"name" example:"Rainy days"` Description string `json:"description" example:""` CollectionType string `json:"collection_type" doc:"manual, smart, or an import source (mdblist, tmdb, trakt)" example:"manual"` - IsShared bool `json:"is_shared" example:"false"` - AllowedProfileIDs []ID `json:"allowed_profile_ids" doc:"Profiles a shared collection is limited to; empty means every profile on the account" example:"[]"` + IsShared bool `json:"is_shared" doc:"Every profile on the login sees the collection read-only, including profiles added later; otherwise only its creator does" example:"false"` QueryDefinition json.RawMessage `json:"query_definition" doc:"Smart-collection query document; {} for a manual collection"` SortConfig json.RawMessage `json:"sort_config" doc:"Default sort document; {} when unset"` - SortOrder int `json:"sort_order" example:"0"` - GroupID *ID `json:"group_id" nullable:"true" doc:"null when ungrouped" example:"g1"` + SortOrder int `json:"sort_order" doc:"Position in its creator's own order of collections" example:"0"` + GroupID *ID `json:"group_id" nullable:"true" doc:"Always null: personal collection groups are no longer supported" example:"g1"` SourceURL string `json:"source_url" doc:"Where an imported collection is synced from; empty otherwise" example:""` SourceConfig json.RawMessage `json:"source_config,omitempty" doc:"Import source document; absent for a manual or smart collection"` SyncSchedule string `json:"sync_schedule" doc:"Empty when the collection is not synced" example:""` + SyncCadence string `json:"sync_cadence" enum:",daily,weekly,monthly,custom" doc:"The cadence sync_schedule names; empty when the collection is not synced, custom for a schedule no cadence name produces" example:""` NextSyncAt *Instant `json:"next_sync_at" nullable:"true" example:"2026-01-02T03:04:05.678Z"` LastSyncAt *Instant `json:"last_sync_at" nullable:"true" example:"2026-01-02T03:04:05.678Z"` LastSyncStatus string `json:"last_sync_status" doc:"Empty until the first sync" example:""` @@ -44,8 +47,10 @@ type PersonalCollection struct { IncludeInServerCollections bool `json:"include_in_server_collections" example:"false"` PosterURL string `json:"poster_url" doc:"Presigned, short-lived; empty when there is no poster" example:""` PosterThumbhash string `json:"poster_thumbhash" example:""` + PosterIsCollage bool `json:"poster_is_collage" doc:"poster_url is a collage of the collection's first titles the acting profile can see, composed by the server because the collection has no uploaded or imported poster. False for an uploaded or imported poster and whenever poster_url is empty: before the collage is built, when no title the profile can see has a poster, and on getCollection and updateCollection, which carry no poster_url for any poster because their body sits behind a strong ETag. See getCollectionCapabilities poster_collages" example:"false"` CreatedAt Instant `json:"created_at" example:"2026-01-02T03:04:05.678Z"` UpdatedAt Instant `json:"updated_at" example:"2026-01-02T03:04:05.678Z"` + Contains *bool `json:"contains,omitempty" doc:"Whether the collection holds the listCollections contains_item title. Present only when contains_item is sent, and then only on the acting profile's own manual collections; false for a title the profile cannot access" example:"true"` } // CollectionGroup is an account-wide grouping of personal collections. @@ -58,10 +63,16 @@ type CollectionGroup struct { } // PersonalCollectionCollection is the listCollections envelope: the -// profile's visible collections plus the account's groups. +// profile's own collections, then the shared collections of other profiles on +// the login. Groups is always empty. type PersonalCollectionCollection struct { Collection[PersonalCollection] - Groups []CollectionGroup `json:"groups" doc:"The account's collection groups in sort order; empty, never null"` + Groups []CollectionGroup `json:"groups" doc:"Always empty: personal collection groups are no longer supported"` +} + +// PersonalCollectionListInput is the listCollections request. +type PersonalCollectionListInput struct { + ContainsItem string `query:"contains_item" doc:"A title's content id. Each of the acting profile's own manual collections then carries contains. Accepted when getCollectionCapabilities reports contains_item" example:"01J9Z8C3W4R5T6Y7U8I9O0P1Q5"` } // PersonalCollectionCollectionOutput is the listCollections response. @@ -84,9 +95,9 @@ type PersonalCollectionCreatedOutput struct { // PersonalCollectionCreate is the createCollection body. type PersonalCollectionCreate struct { Name string `json:"name" minLength:"1" example:"Rainy days"` + Description *string `json:"description,omitempty" nullable:"false" doc:"Empty when omitted. Send only when getCollectionCapabilities reports create_description; otherwise the request fails" example:"For wet afternoons"` CollectionType *string `json:"collection_type,omitempty" nullable:"false" enum:"manual,smart" doc:"Defaults to manual" example:"manual"` - IsShared *bool `json:"is_shared,omitempty" nullable:"false" example:"false"` - AllowedProfileIDs *[]ID `json:"allowed_profile_ids,omitempty" nullable:"false" doc:"Profiles a shared collection is limited to" example:"[]"` + IsShared *bool `json:"is_shared,omitempty" nullable:"false" doc:"Show the collection to every profile on the login; defaults to false" example:"false"` QueryDefinition json.RawMessage `json:"query_definition,omitempty" doc:"Smart-collection query document; required to be valid when collection_type is smart"` SortConfig json.RawMessage `json:"sort_config,omitempty" doc:"Default sort document"` DisplayQueryDefinition json.RawMessage `json:"display_query_definition,omitempty" doc:"Display filter fragment"` @@ -102,8 +113,8 @@ type PersonalCollectionCreateInput struct { // CollectionOrder is the reorderCollections body. type CollectionOrder struct { - GroupID *ID `json:"group_id,omitempty" nullable:"true" doc:"The group whose collections are ordered; omitted or null orders the ungrouped section" example:"g1"` - OrderedIDs []ID `json:"ordered_ids" doc:"Every visible collection in the scope, exactly once, in the new order" example:"[\"01J9Z8C3W4R5T6Y7U8I9O0P1Q4\"]"` + GroupID *ID `json:"group_id,omitempty" nullable:"true" doc:"Omit or send null: personal collection groups are no longer supported, and any other value is a validation failure" example:"g1"` + OrderedIDs []ID `json:"ordered_ids" doc:"Each of the acting profile's own collections, exactly once, in the new order; another profile's collection is a validation failure" example:"[\"01J9Z8C3W4R5T6Y7U8I9O0P1Q4\"]"` } // CollectionOrderInput is the reorderCollections request. @@ -117,7 +128,8 @@ type CollectionOrderInput struct { // detect before using a member. type CollectionCapabilities struct { Capability - Groups bool `json:"groups" doc:"The acting account supports collection groups"` + Groups bool `json:"groups" doc:"Always false: personal collection groups are no longer supported"` + LoginSharing bool `json:"login_sharing" doc:"is_shared shows a collection to every profile on the login, listCollections includes other profiles' shared collections, and only a collection's creator changes or orders it"` Imports bool `json:"imports" doc:"The acting account supports imported collections"` ImportSources []string `json:"import_sources" enum:"mdblist,tmdb,tmdb_list" doc:"Import sources the acting account can create a collection from; empty when imports is false" example:"[\"mdblist\",\"tmdb\",\"tmdb_list\"]"` Artwork bool `json:"artwork" doc:"The acting account supports collection artwork"` @@ -128,6 +140,13 @@ type CollectionCapabilities struct { CollectionSortPreferences bool `json:"collection_sort_preferences" example:"true"` EffectiveCollectionSort bool `json:"effective_collection_sort" example:"true"` SortPreferenceKinds []string `json:"sort_preference_kinds" doc:"collection_kind values the sort-preference operations accept" example:"[\"library\",\"user\",\"watchlist\",\"favorites\"]"` + CreateDescription bool `json:"create_description" doc:"createCollection stores a description for the acting account" example:"true"` + MDBListSearch bool `json:"mdblist_search" doc:"searchMDBListLists and listTopMDBListLists return lists; false when the server has no MDBList API key" example:"true"` + ScheduleTimeZone CollectionScheduleTimeZone `json:"schedule_time_zone"` + SyncScheduleEditable bool `json:"sync_schedule_editable" doc:"updateCollection accepts sync_schedule on a synced list; false when imports is false" example:"true"` + ContainsItem bool `json:"contains_item" doc:"listCollections accepts contains_item and marks the acting profile's own manual collections with contains" example:"true"` + PreviewPosters bool `json:"preview_posters" doc:"previewCollection items carry poster_url when the title has a poster" example:"true"` + PosterCollages bool `json:"poster_collages" doc:"A collection with no uploaded or imported poster shows a collage of its first titles the acting profile can see in poster_url, marked by poster_is_collage, on listCollections, getLibraryCollections and listLibraryUserCollections, once the server has built it. False when the server has no artwork storage or the acting account's store keeps no artwork" example:"true"` } // importableCollectionSources are the import sources a new collection can be @@ -142,6 +161,10 @@ const ( importSourceTMDBList = "tmdb_list" ) +// collectionTypeManual is the collection_type of a collection whose titles +// are added by hand. +const collectionTypeManual = "manual" + // collectionImportSources reports importableCollectionSources when imports // are supported and an empty list otherwise. func collectionImportSources(imports bool) []string { @@ -231,9 +254,9 @@ type CollectionImportBase struct { Description *string `json:"description,omitempty" nullable:"false" example:""` Limit *int `json:"limit,omitempty" nullable:"false" minimum:"1" doc:"Cap on synced items; the server's own maximum applies when omitted" example:"50"` SyncSchedule *string `json:"sync_schedule,omitempty" nullable:"false" doc:"Sync cadence name; the server default when omitted" example:"daily"` - IsShared *bool `json:"is_shared,omitempty" nullable:"false" example:"false"` + IsShared *bool `json:"is_shared,omitempty" nullable:"false" doc:"Show the collection to every profile on the login; defaults to false" example:"false"` PosterURL *string `json:"poster_url,omitempty" nullable:"false" doc:"A bundled template poster path or an image URL" example:""` - LibraryIDs *[]ID `json:"library_ids,omitempty" nullable:"false" doc:"Libraries the sync matches against; every library when omitted" example:"[\"1\"]"` + LibraryIDs *[]ID `json:"library_ids,omitempty" nullable:"false" doc:"Libraries the sync matches against, limited to the libraries the collection's owner can access; every library the owner can access when omitted" example:"[\"1\"]"` DisplayQueryDefinition json.RawMessage `json:"display_query_definition,omitempty" doc:"Display filter fragment"` SortConfig json.RawMessage `json:"sort_config,omitempty" doc:"Default sort document"` } @@ -344,9 +367,10 @@ type MDBListSearchInput struct { // handler writes and an *handlers.APIError on failure. type PersonalCollectionService interface { ListPersonalCollections(ctx context.Context, userID int, profileID string) (handlers.PersonalCollectionListView, error) + PersonalCollectionsHoldingItem(ctx context.Context, userID int, profileID, itemID string) (map[string]bool, error) Capabilities() handlers.CollectionCapabilitiesView CreatePersonalCollection(ctx context.Context, cmd handlers.PersonalCollectionCreateCommand) (handlers.PersonalCollectionView, error) - ReorderPersonalCollections(ctx context.Context, userID int, profileID string, groupID *string, orderedIDs []string) error + ReorderPersonalCollections(ctx context.Context, userID int, profileID string, orderedIDs []string) error CreateCollectionGroup(ctx context.Context, userID int, req handlers.CollectionGroupCreateRequest) (handlers.CollectionGroupView, error) UpdateCollectionGroup(ctx context.Context, userID int, id string, req handlers.CollectionGroupUpdateRequest) (handlers.CollectionGroupView, error) DeleteCollectionGroup(ctx context.Context, userID int, id string) error @@ -363,8 +387,13 @@ type CollectionImportService interface { ImportTrakt(ctx context.Context, userID int, profileID string, req handlers.UserImportTraktRequest) (handlers.UserImportView, error) SearchMDBList(ctx context.Context, query string) (handlers.MDBListDiscoveryView, error) TopMDBList(ctx context.Context) (handlers.MDBListDiscoveryView, error) + MDBListConfigured() bool } +// groupsRemovedSummary describes each personal collection group operation, +// kept (answering 501) until native clients tolerate its absence (#1615). +const groupsRemovedSummary = "Not supported: personal collection groups were removed, and this operation answers capability_unsupported." + const ( opReorderCollections = "reorderCollections" opReorderCollectionGroups = "reorderCollectionGroups" @@ -387,38 +416,46 @@ func registerPersonalCollections(reg *Registry) { } Register(reg, read(humaOp(http.MethodGet, Prefix+"/collections", "listCollections", "collections", - "List the collections the acting profile owns or may see, with the account's collection groups.")), reg.listCollections) + "List the acting profile's own collections in its order, then the collections other profiles on the login share, grouped by creator and each in its creator's order.")), reg.listCollections) create := humaOp(http.MethodPost, Prefix+"/collections", "createCollection", "collections", "Create a manual or smart collection for the acting profile. Not idempotent: a retry after a lost response creates a second collection.") create.DefaultStatus = http.StatusCreated - Register(reg, write(create), reg.createCollection) + createOp := write(create) + // A description or poster the acting account's store cannot keep. + createOp.Errors = append(createOp.Errors, http.StatusNotImplemented) + Register(reg, createOp, reg.createCollection) Register(reg, read(humaOp(http.MethodGet, Prefix+"/collections/capabilities", "getCollectionCapabilities", "collections", "The collection features this server supports.")), reg.getCollectionCapabilities) order := humaOp(http.MethodPut, Prefix+"/collections/order", opReorderCollections, "collections", - "Replace the order of the collections in one group (or the ungrouped section). Retries are not safe after an intervening mutation.") + "Replace the order of the acting profile's own collections. Retries are not safe after an intervening mutation.") order.DefaultStatus = http.StatusOK Register(reg, write(order), reg.reorderCollections) + groupOp := func(op huma.Operation) Operation { + o := write(op) + o.Errors = append(o.Errors, http.StatusNotImplemented) + return o + } createGroup := humaOp(http.MethodPost, Prefix+"/collections/groups", "createCollectionGroup", "collections", - "Create an account-wide collection group. Not idempotent: a retry after a lost response creates a second group.") + groupsRemovedSummary) createGroup.DefaultStatus = http.StatusCreated - Register(reg, write(createGroup), reg.createCollectionGroup) + Register(reg, groupOp(createGroup), reg.createCollectionGroup) groupOrder := humaOp(http.MethodPut, Prefix+"/collections/groups/order", opReorderCollectionGroups, "collections", - "Replace the order of the account's collection groups. Retries are not safe after an intervening mutation.") + groupsRemovedSummary) groupOrder.DefaultStatus = http.StatusOK - Register(reg, write(groupOrder), reg.reorderCollectionGroups) + Register(reg, groupOp(groupOrder), reg.reorderCollectionGroups) - Register(reg, write(humaOp(http.MethodPatch, Prefix+"/collections/groups/{id}", opUpdateCollectionGroup, "collections", - "Update a collection group; omitted members are unchanged. Retries are not safe after an intervening mutation.")), reg.updateCollectionGroup) + Register(reg, groupOp(humaOp(http.MethodPatch, Prefix+"/collections/groups/{id}", opUpdateCollectionGroup, "collections", + groupsRemovedSummary)), reg.updateCollectionGroup) deleteGroup := humaOp(http.MethodDelete, Prefix+"/collections/groups/{id}", opDeleteCollectionGroup, "collections", - "Delete a collection group; its collections become ungrouped.") + groupsRemovedSummary) deleteGroup.DefaultStatus = http.StatusNoContent - Register(reg, write(deleteGroup), reg.deleteCollectionGroup) + Register(reg, groupOp(deleteGroup), reg.deleteCollectionGroup) importOp := func(path, id, summary string) huma.Operation { op := humaOp(http.MethodPost, Prefix+path, id, "collections", @@ -483,19 +520,15 @@ func collectionProblem(err error) *Problem { } func personalCollectionOf(v handlers.PersonalCollectionView) PersonalCollection { - ids := make([]ID, 0, len(v.AllowedProfileIDs)) - for _, id := range v.AllowedProfileIDs { - ids = append(ids, ID(id)) - } out := PersonalCollection{ ID: ID(v.ID), ProfileID: ID(v.ProfileID), CreatorProfileID: ID(v.CreatorProfileID), Name: v.Name, Description: v.Description, CollectionType: v.CollectionType, IsShared: v.IsShared, - AllowedProfileIDs: ids, QueryDefinition: jsonDocument(v.QueryDefinition), SortConfig: jsonDocument(v.SortConfig), - SortOrder: v.SortOrder, SourceURL: v.SourceURL, SyncSchedule: v.SyncSchedule, + QueryDefinition: jsonDocument(v.QueryDefinition), SortConfig: jsonDocument(v.SortConfig), + SortOrder: v.SortOrder, SourceURL: v.SourceURL, SyncSchedule: v.SyncSchedule, SyncCadence: usercollections.CadenceOf(v.SyncSchedule), NextSyncAt: instantOfStamp(v.NextSyncAt), LastSyncAt: instantOfStamp(v.LastSyncAt), LastSyncStatus: v.LastSyncStatus, LastSyncMessage: v.LastSyncMessage, ItemCount: v.ItemCount, IncludeInServerCollections: v.IncludeInServerCollections, - PosterURL: v.PosterURL, PosterThumbhash: v.PosterThumbhash, + PosterURL: v.PosterURL, PosterThumbhash: v.PosterThumbhash, PosterIsCollage: v.PosterIsCollage && v.PosterURL != "", } if v.GroupID != nil { out.GroupID = new(ID(*v.GroupID)) @@ -542,7 +575,7 @@ func intsOfIDs(ids []ID, member string) ([]int, *Problem) { return out, nil } -func (reg *Registry) listCollections(ctx context.Context, _ *struct{}) (*PersonalCollectionCollectionOutput, error) { +func (reg *Registry) listCollections(ctx context.Context, in *PersonalCollectionListInput) (*PersonalCollectionCollectionOutput, error) { svc, p := reg.personalCollections() if p != nil { return nil, p @@ -551,13 +584,28 @@ func (reg *Registry) listCollections(ctx context.Context, _ *struct{}) (*Persona if p != nil { return nil, p } - view, err := svc.ListPersonalCollections(ctx, userID, profileFrom(ctx)) + profileID := profileFrom(ctx) + // Marked as /api/v2, the read shows collections' collages (#1618). + view, err := svc.ListPersonalCollections(handlers.WithNativeAPIV2(ctx), userID, profileID) if err != nil { return nil, collectionProblem(err) } + marking := in.ContainsItem != "" + var holding map[string]bool + if marking { + if holding, err = svc.PersonalCollectionsHoldingItem(ctx, userID, profileID, in.ContainsItem); err != nil { + return nil, collectionProblem(err) + } + } items := make([]PersonalCollection, 0, len(view.Collections)) for _, c := range view.Collections { - items = append(items, personalCollectionOf(c)) + item := personalCollectionOf(c) + // Only the profile's own manual collections take a title from Add + // to collection; every other collection leaves contains out. + if marking && c.CreatorProfileID == profileID && c.CollectionType == collectionTypeManual { + item.Contains = new(holding[c.ID]) + } + items = append(items, item) } groups := make([]CollectionGroup, 0, len(view.Groups)) for _, g := range view.Groups { @@ -569,7 +617,7 @@ func (reg *Registry) listCollections(ctx context.Context, _ *struct{}) (*Persona func (reg *Registry) getCollectionCapabilities(ctx context.Context, _ *CapabilityInput) (*CollectionCapabilitiesOutput, error) { svc := reg.deps.PersonalCollections if svc == nil { - return &CollectionCapabilitiesOutput{Body: CollectionCapabilities{Capability: Capability{State: StateNotConfigured}, DisplayFilterFields: []string{}, DisplayFilterPresets: CollectionDisplayFilterPresets{Watched: []string{}, Media: []string{}}, SortPreferenceKinds: []string{}}}, nil + return &CollectionCapabilitiesOutput{Body: CollectionCapabilities{Capability: Capability{State: StateNotConfigured}, DisplayFilterFields: []string{}, DisplayFilterPresets: CollectionDisplayFilterPresets{Watched: []string{}, Media: []string{}}, SortPreferenceKinds: []string{}, MDBListSearch: reg.mdblistSearch(), ScheduleTimeZone: reg.scheduleTimeZone()}}, nil } v := svc.Capabilities() features := userstore.CollectionFeatures{} @@ -587,7 +635,7 @@ func (reg *Registry) getCollectionCapabilities(ctx context.Context, _ *Capabilit } } return &CollectionCapabilitiesOutput{Body: CollectionCapabilities{ - Groups: features.Groups, Imports: features.Imports, Artwork: features.Artwork, ItemReorder: features.ItemReorder, + Groups: features.Groups, LoginSharing: true, Imports: features.Imports, Artwork: features.Artwork, ItemReorder: features.ItemReorder, ImportSources: collectionImportSources(features.Imports), DisplayFilterFields: NonNil(v.DisplayFilterFields), DisplayFilterPresets: CollectionDisplayFilterPresets{ @@ -597,6 +645,13 @@ func (reg *Registry) getCollectionCapabilities(ctx context.Context, _ *Capabilit CollectionSortPreferences: v.CollectionSortPreferences, EffectiveCollectionSort: v.EffectiveCollectionSort, SortPreferenceKinds: NonNil(v.SortPreferenceKinds), + CreateDescription: features.Description, + MDBListSearch: reg.mdblistSearch(), + ScheduleTimeZone: reg.scheduleTimeZone(), + SyncScheduleEditable: features.Imports, + ContainsItem: true, + PreviewPosters: true, + PosterCollages: v.PosterCollages && features.Artwork, }}, nil } @@ -623,15 +678,15 @@ func (reg *Registry) createCollection(ctx context.Context, in *PersonalCollectio SortConfig: b.SortConfig, DisplayQueryDefinition: b.DisplayQueryDefinition, } + if b.Description != nil { + req.Description = *b.Description + } if b.CollectionType != nil { req.CollectionType = *b.CollectionType } if b.IsShared != nil { req.IsShared = *b.IsShared } - if b.AllowedProfileIDs != nil { - req.AllowedProfileIDs = stringsOfIDs(*b.AllowedProfileIDs) - } if b.IncludeInServerCollections != nil { req.IncludeInServerCollections = *b.IncludeInServerCollections } @@ -653,20 +708,23 @@ func (reg *Registry) reorderCollections(ctx context.Context, in *CollectionOrder if p != nil { return nil, p } - group := "" - var groupID *string if in.Body.GroupID != nil { - group = string(*in.Body.GroupID) - groupID = &group + return nil, collectionGroupScopeProblem(locationBody + ".group_id") } - guarded, _, p := reg.prepareCollectionGuard(ctx, "order", group, CollectionPreconditions{IfMatch: in.IfMatch, IfNoneMatch: in.IfNoneMatch}) + guarded, _, p := reg.prepareCollectionGuard(ctx, "order", "", CollectionPreconditions{IfMatch: in.IfMatch, IfNoneMatch: in.IfNoneMatch}) if p != nil { return nil, p } - if err := svc.ReorderPersonalCollections(guarded, claimsFrom(ctx).UserID, profileFrom(ctx), groupID, stringsOfIDs(in.Body.OrderedIDs)); err != nil { - return nil, reg.guardedCollectionError(ctx, "order", group, err) + if err := svc.ReorderPersonalCollections(guarded, claimsFrom(ctx).UserID, profileFrom(ctx), stringsOfIDs(in.Body.OrderedIDs)); err != nil { + return nil, reg.guardedCollectionError(ctx, "order", "", err) } - return reg.getCollectionOrder(ctx, &CollectionOrderReadInput{GroupID: group}) + return reg.getCollectionOrder(ctx, &CollectionOrderReadInput{}) +} + +// collectionGroupScopeProblem refuses a group-scoped collection order: each +// profile has one flat order of its own collections. +func collectionGroupScopeProblem(location string) *Problem { + return validationProblem(location, codeInvalid, "Personal collection groups are not supported; omit group_id.") } func (reg *Registry) createCollectionGroup(ctx context.Context, in *CollectionGroupCreateInput) (*CollectionGroupCreatedOutput, error) { diff --git a/internal/apiv2/personal_collections_contains_item_test.go b/internal/apiv2/personal_collections_contains_item_test.go new file mode 100644 index 0000000000..473e4ca369 --- /dev/null +++ b/internal/apiv2/personal_collections_contains_item_test.go @@ -0,0 +1,87 @@ +package apiv2 + +import ( + "encoding/json" + "errors" + "net/http" + "slices" + "testing" + + "github.com/Silo-Server/silo-server/internal/api/handlers" +) + +// TestListCollectionsContainsItem pins contains_item on listCollections: each +// of the acting profile's own manual collections carries contains, and no +// other collection does, so a client can tick Add to collection without +// reading every collection's items. +func TestListCollectionsContainsItem(t *testing.T) { + deps, pc, _ := collectionDeps(t) + view := func(id, creator, kind string) handlers.PersonalCollectionView { + v := fixtureCollectionView() + v.ID, v.CreatorProfileID, v.CollectionType = id, creator, kind + return v + } + pc.list.Collections = []handlers.PersonalCollectionView{ + view("holds", "p-owner", "manual"), + view("lacks", "p-owner", "manual"), + view("smart", "p-owner", "smart"), + view("synced", "p-owner", "mdblist"), + view("shared-with-me", "p-other", "manual"), + } + // The service answers only the profile's own manual collections; the + // mapping must not trust a stray id either. + pc.holding = map[string]bool{"holds": true, "shared-with-me": true} + h := newTestHandler(t, deps) + + containsOf := func(t *testing.T, body []byte) map[string]json.RawMessage { + t.Helper() + var list struct { + Items []map[string]json.RawMessage `json:"items"` + } + if err := json.Unmarshal(body, &list); err != nil { + t.Fatal(err) + } + out := map[string]json.RawMessage{} + for _, item := range list.Items { + var id string + if err := json.Unmarshal(item["id"], &id); err != nil { + t.Fatal(err) + } + if v, ok := item["contains"]; ok { + out[id] = v + } + } + return out + } + + rec := do(t, h, http.MethodGet, "/api/v2/collections?contains_item=title-1", "", viewerHeaders()) + if rec.Code != http.StatusOK { + t.Fatal(rec.Body.String()) + } + got := containsOf(t, rec.Body.Bytes()) + want := map[string]json.RawMessage{"holds": json.RawMessage("true"), "lacks": json.RawMessage("false")} + if len(got) != len(want) || string(got["holds"]) != "true" || string(got["lacks"]) != "false" { + t.Fatalf("contains = %v, want %v", got, want) + } + if !slices.Equal(pc.holdingCalls, []string{"p-owner/title-1"}) { + t.Fatalf("holding calls = %v", pc.holdingCalls) + } + + // Without the parameter no collection carries the member and the + // membership read does not run. + pc.holdingCalls = nil + rec = do(t, h, http.MethodGet, "/api/v2/collections", "", viewerHeaders()) + if rec.Code != http.StatusOK { + t.Fatal(rec.Body.String()) + } + if got := containsOf(t, rec.Body.Bytes()); len(got) != 0 { + t.Fatalf("contains without the parameter = %v", got) + } + if len(pc.holdingCalls) != 0 { + t.Fatalf("holding read without the parameter: %v", pc.holdingCalls) + } + + // A failed membership read fails the list rather than reporting false. + pc.holdingErr = errors.New("boom") + requireProblem(t, do(t, h, http.MethodGet, "/api/v2/collections?contains_item=title-1", "", viewerHeaders()), TypeInternalError) +} diff --git a/internal/apiv2/personal_collections_editors.go b/internal/apiv2/personal_collections_editors.go index bda5172d1d..daa026dcf1 100644 --- a/internal/apiv2/personal_collections_editors.go +++ b/internal/apiv2/personal_collections_editors.go @@ -15,7 +15,7 @@ type CollectionPreconditions struct { IfNoneMatch string `header:"If-None-Match"` } type CollectionOrderReadInput struct { - GroupID string `query:"group_id"` + GroupID string `query:"group_id" doc:"Omit: personal collection groups are no longer supported, and any value is a validation failure"` } type CollectionOrderOutput struct { ETag string `header:"ETag"` @@ -35,7 +35,7 @@ type CollectionItemsOrderOutput struct { } type collectionEditors interface { PersonalCollectionEditor(context.Context, int, string, string) (handlers.PersonalCollectionEditorView, error) - PersonalCollectionOrderEditor(context.Context, int, string, *string) (handlers.PersonalCollectionOrderView, error) + PersonalCollectionOrderEditor(context.Context, int, string) (handlers.PersonalCollectionOrderView, error) PersonalCollectionGroupsEditor(context.Context, int) ([]handlers.CollectionGroupView, int64, error) PersonalCollectionGroupEditor(context.Context, int, string) (handlers.PersonalCollectionGroupEditorView, error) PersonalCollectionItemsOrderEditor(context.Context, int, string, string) (handlers.PersonalCollectionOrderView, error) @@ -73,8 +73,15 @@ func registerCollectionEditors(reg *Registry) { return Operation{Operation: humaOp(http.MethodGet, Prefix+path, id, "collections", "Read the canonical collection editor state and its strong validator."), Class: ClassProfileScoped, ServiceBacked: true} } Register(reg, op("/collections/order", "getCollectionOrder"), reg.getCollectionOrder) - Register(reg, op("/collections/groups/order", "getCollectionGroupsOrder"), reg.getCollectionGroupsOrder) - Register(reg, op("/collections/groups/{id}", "getCollectionGroup"), reg.getCollectionGroup) + // Personal collection groups were removed (#1615): these answer 501. + groupOp := func(path, id string) Operation { + o := op(path, id) + o.Summary = groupsRemovedSummary + o.Errors = append(o.Errors, http.StatusNotImplemented) + return o + } + Register(reg, groupOp("/collections/groups/order", "getCollectionGroupsOrder"), reg.getCollectionGroupsOrder) + Register(reg, groupOp("/collections/groups/{id}", "getCollectionGroup"), reg.getCollectionGroup) Register(reg, op("/collections/{id}/items/order", "getCollectionItemsOrder"), reg.getCollectionItemsOrder) } func (reg *Registry) getCollectionOrder(ctx context.Context, in *CollectionOrderReadInput) (*CollectionOrderOutput, error) { @@ -82,11 +89,10 @@ func (reg *Registry) getCollectionOrder(ctx context.Context, in *CollectionOrder if p != nil { return nil, p } - var group *string if in.GroupID != "" { - group = &in.GroupID + return nil, collectionGroupScopeProblem("query.group_id") } - v, e := s.PersonalCollectionOrderEditor(ctx, claimsFrom(ctx).UserID, profileFrom(ctx), group) + v, e := s.PersonalCollectionOrderEditor(ctx, claimsFrom(ctx).UserID, profileFrom(ctx)) if e != nil { return nil, collectionProblem(e) } @@ -94,11 +100,7 @@ func (reg *Registry) getCollectionOrder(ctx context.Context, in *CollectionOrder for _, id := range v.OrderedIDs { ids = append(ids, ID(id)) } - var groupID *ID - if group != nil { - groupID = new(ID(*group)) - } - return &CollectionOrderOutput{ETag: collectionEditorTag(ctx, "order", in.GroupID, v.Revision).String(), Body: CollectionOrder{GroupID: groupID, OrderedIDs: ids}}, nil + return &CollectionOrderOutput{ETag: collectionEditorTag(ctx, "order", "", v.Revision).String(), Body: CollectionOrder{OrderedIDs: ids}}, nil } func (reg *Registry) getCollectionGroupsOrder(ctx context.Context, _ *struct{}) (*CollectionGroupsOrderOutput, error) { s, p := reg.collectionEditors() @@ -171,12 +173,8 @@ func (reg *Registry) prepareCollectionGuard(ctx context.Context, kind, id string case "groups-order": _, rev, err = s.PersonalCollectionGroupsEditor(ctx, u) case "order": - var group *string - if id != "" { - group = &id - } var v handlers.PersonalCollectionOrderView - v, err = s.PersonalCollectionOrderEditor(ctx, u, profileFrom(ctx), group) + v, err = s.PersonalCollectionOrderEditor(ctx, u, profileFrom(ctx)) rev = v.Revision } if err != nil { diff --git a/internal/apiv2/personal_collections_guard_test.go b/internal/apiv2/personal_collections_guard_test.go index d582812a3a..46fe791e04 100644 --- a/internal/apiv2/personal_collections_guard_test.go +++ b/internal/apiv2/personal_collections_guard_test.go @@ -192,7 +192,9 @@ func TestSQLiteCollectionHTTPPreconditionsAndConcurrentCAS(t *testing.T) { func TestSQLiteCollectionHTTPProfileAccessAndCapabilities(t *testing.T) { h, _ := newCollectionGuardHTTP(t) - shared := guardHTTPCreate(t, h, `{"name":"Shared","is_shared":true,"allowed_profile_ids":["p-primary"]}`) + // A per-profile allow list is gone from the contract: sharing is one switch. + requireProblem(t, do(t, h, http.MethodPost, "/api/v2/collections", `{"name":"Old","is_shared":true,"allowed_profile_ids":["p-primary"]}`, viewerHeaders()), TypeValidationFailed) + shared := guardHTTPCreate(t, h, `{"name":"Shared","is_shared":true}`) viewer := with(bearer(memberToken), "X-Profile-Id", "p-primary") read := guardHTTPGet(t, h, shared, viewer) requireProblem(t, do(t, h, http.MethodPatch, shared, `{"name":"Forbidden"}`, with(viewer, "If-Match", read.Header().Get("ETag"))), TypePermissionDenied) @@ -207,6 +209,7 @@ func TestSQLiteCollectionHTTPProfileAccessAndCapabilities(t *testing.T) { var features struct { Groups, Imports, Artwork bool ItemReorder bool `json:"item_reorder"` + LoginSharing bool `json:"login_sharing"` } if err := json.Unmarshal(caps.Body.Bytes(), &features); err != nil { t.Fatal(err) @@ -214,6 +217,9 @@ func TestSQLiteCollectionHTTPProfileAccessAndCapabilities(t *testing.T) { if features.Groups || features.Imports || features.Artwork || features.ItemReorder { t.Fatalf("SQLite advertised unavailable features: %+v", features) } + if !features.LoginSharing { + t.Fatal("login_sharing is not advertised") + } unsupported := do(t, h, http.MethodPost, "/api/v2/collections/groups", `{"name":"Unavailable"}`, viewerHeaders()) if unsupported.Code != 501 { t.Fatalf("unsupported groups: %d %s", unsupported.Code, unsupported.Body.String()) diff --git a/internal/apiv2/personal_collections_lifecycle.go b/internal/apiv2/personal_collections_lifecycle.go index 04e557c13c..6484745e50 100644 --- a/internal/apiv2/personal_collections_lifecycle.go +++ b/internal/apiv2/personal_collections_lifecycle.go @@ -33,8 +33,7 @@ type PersonalCollectionIDInput struct { type PersonalCollectionUpdate struct { Name *string `json:"name,omitempty" nullable:"false" minLength:"1"` Description *string `json:"description,omitempty" nullable:"false"` - IsShared *bool `json:"is_shared,omitempty" nullable:"false"` - AllowedProfileIDs *[]ID `json:"allowed_profile_ids,omitempty" nullable:"false"` + IsShared *bool `json:"is_shared,omitempty" nullable:"false" doc:"Show the collection to every profile on the login"` QueryDefinition json.RawMessage `json:"query_definition,omitempty"` SortConfig json.RawMessage `json:"sort_config,omitempty"` SourceURL *string `json:"source_url,omitempty" nullable:"false"` @@ -43,7 +42,8 @@ type PersonalCollectionUpdate struct { DisplayQueryDefinition json.RawMessage `json:"display_query_definition,omitempty"` IncludeInServerCollections *bool `json:"include_in_server_collections,omitempty" nullable:"false"` PosterSourceURL *string `json:"poster_source_url,omitempty" nullable:"false"` - GroupID *ID `json:"group_id,omitempty" nullable:"true" doc:"Null removes the group; omitted leaves it unchanged"` + GroupID *ID `json:"group_id,omitempty" nullable:"true" doc:"Omit: personal collection groups are no longer supported, and setting it answers capability_unsupported"` + SyncSchedule *string `json:"sync_schedule,omitempty" nullable:"false" enum:",daily,weekly,monthly" doc:"A synced list's cadence; empty stops scheduled syncs. Cron expressions are refused. Accepted when getCollectionCapabilities reports sync_schedule_editable" example:"weekly"` } type PersonalCollectionUpdateInput struct { IfMatch string `header:"If-Match"` @@ -81,6 +81,7 @@ type PersonalCollectionPreviewItem struct { ContentID ID `json:"content_id"` Title string `json:"title"` Type string `json:"type"` + PosterURL string `json:"poster_url,omitempty" doc:"Card-size poster URL; omitted when the item has none"` } type PersonalCollectionPreviewOutput struct { Body struct { @@ -156,7 +157,9 @@ func registerPersonalCollectionLifecycle(reg *Registry) { upload.MaxBodyBytes = maxPosterBytes + posterFormOverhead Register(reg, upload, reg.uploadPersonalCollectionPoster) Register(reg, op(http.MethodGet, "/collections/{id}", "getCollection", "Read a collection visible to the acting profile."), reg.getPersonalCollection) - Register(reg, op(http.MethodPatch, "/collections/{id}", "updateCollection", "Update the creator's collection; omitted fields are unchanged."), reg.updatePersonalCollection) + update := op(http.MethodPatch, "/collections/{id}", "updateCollection", "Update the creator's collection; omitted fields are unchanged. Another profile's shared collection answers permission_denied.") + update.Errors = append(update.Errors, http.StatusNotImplemented) // group_id, or a feature the account's store lacks + Register(reg, update, reg.updatePersonalCollection) noContent := func(method, path, id, summary string) Operation { o := op(method, path, id, summary) o.DefaultStatus = http.StatusNoContent @@ -207,10 +210,7 @@ func (reg *Registry) updatePersonalCollection(ctx context.Context, in *PersonalC return nil, NewProblem(TypeValidationFailed, "Update artwork using the separate poster operation.") } b := in.Body - r := handlers.PersonalCollectionUpdateRequest{Name: b.Name, Description: b.Description, IsShared: b.IsShared, QueryDefinition: b.QueryDefinition, SortConfig: b.SortConfig, SourceURL: b.SourceURL, MaxItems: b.MaxItems, DisplayQueryDefinition: b.DisplayQueryDefinition, IncludeInServerCollections: b.IncludeInServerCollections, PosterSourceURL: b.PosterSourceURL} - if b.AllowedProfileIDs != nil { - r.AllowedProfileIDs = new(stringsOfIDs(*b.AllowedProfileIDs)) - } + r := handlers.PersonalCollectionUpdateRequest{Name: b.Name, Description: b.Description, IsShared: b.IsShared, QueryDefinition: b.QueryDefinition, SortConfig: b.SortConfig, SourceURL: b.SourceURL, MaxItems: b.MaxItems, DisplayQueryDefinition: b.DisplayQueryDefinition, IncludeInServerCollections: b.IncludeInServerCollections, PosterSourceURL: b.PosterSourceURL, SyncSchedule: b.SyncSchedule} if b.LibraryIDs != nil { ids, p := intsOfIDs(*b.LibraryIDs, "library_ids") if p != nil { @@ -294,13 +294,13 @@ func (reg *Registry) previewPersonalCollection(ctx context.Context, in *Personal if p != nil { return nil, p } - v, e := s.PreviewPersonalCollection(ctx, handlers.PersonalCollectionPreviewRequest{QueryDefinition: in.Body.QueryDefinition, Limit: in.Body.Limit}, handlers.AccessFilterFromContext(ctx, "")) + v, e := s.PreviewPersonalCollection(ctx, handlers.PersonalCollectionPreviewRequest{QueryDefinition: in.Body.QueryDefinition, Limit: in.Body.Limit, WithPosters: true}, handlers.AccessFilterFromContext(ctx, "")) if e != nil { return nil, collectionProblem(e) } items := make([]PersonalCollectionPreviewItem, 0, len(v.Items)) for _, i := range v.Items { - items = append(items, PersonalCollectionPreviewItem{ContentID: ID(i.ContentID), Title: i.Title, Type: i.Type}) + items = append(items, PersonalCollectionPreviewItem{ContentID: ID(i.ContentID), Title: i.Title, Type: i.Type, PosterURL: i.PosterURL}) } out := &PersonalCollectionPreviewOutput{} out.Body.Collection = NewCollection(items) @@ -356,14 +356,15 @@ func (reg *Registry) listPersonalCollectionTemplates(_ context.Context, _ *struc if !ok { return nil, unavailable("collection templates") } - return &PersonalCollectionTemplatesOutput{Body: importableCollectionTemplates(s.CollectionTemplates())}, nil + return &PersonalCollectionTemplatesOutput{Body: creatableCollectionTemplates(s.CollectionTemplates())}, nil } -// importableCollectionTemplates keeps the templates a personal collection can -// be created from. The shared catalog also lists TMDB Discover and franchise -// templates, which only admin template bundles can apply; a category left -// with no templates is dropped. -func importableCollectionTemplates(catalog templates.Catalog) templates.Catalog { +// creatableCollectionTemplates keeps the templates whose source is one of +// importableCollectionSources (mdblist, tmdb, tmdb_list), the set a single +// collection can be created from. The shared catalog also lists TMDB Discover and +// franchise templates, which only admin template bundles can apply; a +// category left with no templates is dropped. +func creatableCollectionTemplates(catalog templates.Catalog) templates.Catalog { out := templates.Catalog{Categories: make([]templates.CategoryGroup, 0, len(catalog.Categories))} for _, group := range catalog.Categories { kept := make([]templates.Template, 0, len(group.Templates)) @@ -411,11 +412,7 @@ func (reg *Registry) listServerCollections(ctx context.Context, _ *struct{}) (*S out := &ServerCollectionsOutput{} out.Body.Libraries = make([]ServerCollectionLibrary, 0, len(v.Libraries)) for _, l := range v.Libraries { - cards := make([]LibraryCollectionCard, 0, len(l.Collections)) - for _, c := range l.Collections { - cards = append(cards, LibraryCollectionCard{ID: c.ID, Title: c.Title, PosterURL: c.PosterURL, PosterThumbhash: c.PosterThumbhash, ItemCount: c.ItemCount, Featured: c.Featured}) - } - out.Body.Libraries = append(out.Body.Libraries, ServerCollectionLibrary{LibraryID: IDFromInt(int64(l.LibraryID)), LibraryName: l.LibraryName, TotalCount: l.TotalCount, Collections: cards}) + out.Body.Libraries = append(out.Body.Libraries, ServerCollectionLibrary{LibraryID: IDFromInt(int64(l.LibraryID)), LibraryName: l.LibraryName, TotalCount: l.TotalCount, Collections: collectionCardsOf(l.Collections)}) } return out, nil } diff --git a/internal/apiv2/personal_collections_preview_test.go b/internal/apiv2/personal_collections_preview_test.go new file mode 100644 index 0000000000..33d670fb25 --- /dev/null +++ b/internal/apiv2/personal_collections_preview_test.go @@ -0,0 +1,65 @@ +package apiv2 + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/Silo-Server/silo-server/internal/api/handlers" + catalogsvc "github.com/Silo-Server/silo-server/internal/catalog" +) + +// previewCollections answers only the smart preview. The embedded lifecycle +// interface stays nil: no other lifecycle method is called through it. +type previewCollections struct { + personalCollectionLifecycle + view handlers.PersonalCollectionPreviewView + req handlers.PersonalCollectionPreviewRequest +} + +func (p *previewCollections) PreviewPersonalCollection(_ context.Context, req handlers.PersonalCollectionPreviewRequest, _ catalogsvc.AccessFilter) (handlers.PersonalCollectionPreviewView, error) { + p.req = req + return p.view, nil +} + +func fixturePreviewView() handlers.PersonalCollectionPreviewView { + return handlers.PersonalCollectionPreviewView{Total: 2, Items: []handlers.PersonalCollectionPreviewItemView{ + {ContentID: "movie:heat-1995", Title: "Heat", Type: "movie", PosterURL: "https://img.example.test/heat.jpg"}, + {ContentID: "movie:ronin-1998", Title: "Ronin", Type: "movie"}, + }} +} + +func TestPreviewCollectionPosterURLs(t *testing.T) { + deps, pc, _ := collectionDeps(t) + preview := &previewCollections{view: fixturePreviewView()} + deps.PersonalCollections = struct { + *fakePersonalCollections + *previewCollections + }{pc, preview} + h := newTestHandler(t, deps) + rec := do(t, h, http.MethodPost, "/api/v2/collections/preview", `{"query_definition":{"match":"all","groups":[]},"limit":5}`, viewerHeaders()) + if rec.Code != http.StatusOK { + t.Fatalf("%d %s", rec.Code, rec.Body.String()) + } + if preview.req.Limit != 5 || !preview.req.WithPosters || string(preview.req.QueryDefinition) != `{"match":"all","groups":[]}` { + t.Fatalf("request = %+v", preview.req) + } + var body struct { + Items []map[string]any `json:"items"` + Total int `json:"total"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Total != 2 || len(body.Items) != 2 { + t.Fatalf("body = %s", rec.Body.String()) + } + // The item with a poster carries its URL; the one without omits the member. + if got := body.Items[0]["poster_url"]; got != "https://img.example.test/heat.jpg" { + t.Fatalf("first poster_url = %v, body %s", got, rec.Body.String()) + } + if _, ok := body.Items[1]["poster_url"]; ok { + t.Fatalf("item without a poster has poster_url: %s", rec.Body.String()) + } +} diff --git a/internal/apiv2/personal_collections_sync_schedule_test.go b/internal/apiv2/personal_collections_sync_schedule_test.go new file mode 100644 index 0000000000..0a3a00d207 --- /dev/null +++ b/internal/apiv2/personal_collections_sync_schedule_test.go @@ -0,0 +1,174 @@ +package apiv2 + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/api/handlers" + "github.com/Silo-Server/silo-server/internal/usercollections" + "github.com/Silo-Server/silo-server/internal/userstore" + "github.com/Silo-Server/silo-server/internal/userstore/pgstore" +) + +// TestUpdateCollectionSyncScheduleDB drives updateCollection's sync_schedule +// and the sync_cadence reads through the real collection handler and +// Postgres stores, for a regular account and an admin account. +func TestUpdateCollectionSyncScheduleDB(t *testing.T) { + dsn := os.Getenv("SILO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("SILO_TEST_DATABASE_URL is not set") + } + ctx := t.Context() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + pg := pgstore.NewPostgresProvider(pool) + // The test tokens name accounts 1 (member) and 2 (admin); each maps to a + // fresh Postgres account. + provider := collectionGuardHTTPProvider{stores: map[int]userstore.UserStore{}} + suffix := time.Now().UnixNano() + for tokenUser, role := range map[int]string{1: "user", 2: "admin"} { + var account int + if err := pool.QueryRow(ctx, `INSERT INTO users(username,role) VALUES($1,$2) RETURNING id`, fmt.Sprintf("v2-sync-schedule-%d-%d", suffix, tokenUser), role).Scan(&account); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.WithoutCancel(ctx), `DELETE FROM users WHERE id=$1`, account) + _, _ = pool.Exec(context.WithoutCancel(ctx), `DELETE FROM user_collection_revisions WHERE user_id=$1`, account) + }) + store, err := pg.ForUser(ctx, account) + if err != nil { + t.Fatal(err) + } + provider.stores[tokenUser] = store + } + member, admin := provider.stores[1], provider.stores[2] + memberHeaders, adminHeaders := viewerHeaders(), with(bearer(adminToken), "X-Profile-Id", "p-primary") + if err := member.CreateProfile(ctx, userstore.Profile{ID: "p-owner", Name: "Owner"}); err != nil { + t.Fatal(err) + } + if err := admin.CreateProfile(ctx, userstore.Profile{ID: "p-primary", Name: "Admin", IsPrimary: true}); err != nil { + t.Fatal(err) + } + deps := pilotDeps(nil, nil) + deps.PersonalCollections = handlers.NewCollectionHandler(provider) + h := newTestHandler(t, deps) + + daily := usercollections.AllowedSyncSchedules["daily"] + createSynced := func(t *testing.T, store userstore.UserStore, profile string) string { + t.Helper() + next := time.Now().Add(12 * time.Hour) + c, err := store.CreateCollection(ctx, userstore.CreateCollectionInput{ + CreatorProfileID: profile, Name: "Synced", CollectionType: "mdblist", QueryDefinition: "{}", + SourceURL: "https://mdblist.com/lists/u/top", SourceConfig: `{"mode":"mdblist","url":"https://mdblist.com/lists/u/top"}`, + SyncSchedule: &daily, NextSyncAt: &next, + }) + if err != nil { + t.Fatal(err) + } + return "/api/v2/collections/" + c.ID + } + type collection struct { + SyncSchedule string `json:"sync_schedule"` + SyncCadence *string `json:"sync_cadence"` + NextSyncAt *string `json:"next_sync_at"` + } + read := func(t *testing.T, path string, headers map[string]string) (collection, string) { + t.Helper() + rec := guardHTTPGet(t, h, path, headers) + var c collection + if err := json.Unmarshal(rec.Body.Bytes(), &c); err != nil { + t.Fatal(err) + } + return c, rec.Header().Get("ETag") + } + patch := func(t *testing.T, path, body string, headers map[string]string) int { + t.Helper() + _, tag := read(t, path, headers) + return do(t, h, http.MethodPatch, path, body, with(headers, "If-Match", tag)).Code + } + requireSchedule := func(t *testing.T, path string, headers map[string]string, schedule, cadence string, next bool) { + t.Helper() + c, _ := read(t, path, headers) + if c.SyncSchedule != schedule || c.SyncCadence == nil || *c.SyncCadence != cadence || (c.NextSyncAt != nil) != next { + t.Fatalf("sync_schedule %q, sync_cadence %v, next_sync_at %v; want %q, %q, next %v", c.SyncSchedule, c.SyncCadence, c.NextSyncAt, schedule, cadence, next) + } + } + requireRefused := func(t *testing.T, path, body string, headers map[string]string) { + t.Helper() + _, tag := read(t, path, headers) + p := requireProblem(t, do(t, h, http.MethodPatch, path, body, with(headers, "If-Match", tag)), TypeValidationFailed) + if len(p.Errors) != 1 || p.Errors[0].Location != "body.sync_schedule" { + t.Fatalf("errors = %+v", p.Errors) + } + if _, after := read(t, path, headers); after != tag { + t.Fatalf("refused update changed the collection: ETag %s, was %s", after, tag) + } + } + + t.Run("reads report the cadence", func(t *testing.T) { + path := createSynced(t, member, "p-owner") + requireSchedule(t, path, memberHeaders, daily, "daily", true) + // A stored expression no cadence name produces reads as custom. + id := strings.TrimPrefix(path, "/api/v2/collections/") + if _, err := pool.Exec(ctx, `UPDATE user_personal_collections SET sync_schedule='*/30 * * * *' WHERE id=$1`, id); err != nil { + t.Fatal(err) + } + requireSchedule(t, path, memberHeaders, "*/30 * * * *", "custom", true) + manual := guardHTTPCreate(t, h, `{"name":"Manual","collection_type":"manual"}`) + requireSchedule(t, manual, memberHeaders, "", "", false) + }) + + t.Run("a named cadence replaces the schedule", func(t *testing.T) { + path := createSynced(t, member, "p-owner") + for _, cadence := range []string{"weekly", "monthly", "daily"} { + if code := patch(t, path, `{"sync_schedule":"`+cadence+`"}`, memberHeaders); code != http.StatusOK { + t.Fatalf("%s: status %d", cadence, code) + } + requireSchedule(t, path, memberHeaders, usercollections.AllowedSyncSchedules[cadence], cadence, true) + } + if code := patch(t, path, `{"sync_schedule":""}`, memberHeaders); code != http.StatusOK { + t.Fatalf("off: status %d", code) + } + requireSchedule(t, path, memberHeaders, "", "", false) + }) + + t.Run("cron expressions are refused for every account", func(t *testing.T) { + for name, account := range map[string]struct { + store userstore.UserStore + profile string + headers map[string]string + }{"regular": {member, "p-owner", memberHeaders}, "admin": {admin, "p-primary", adminHeaders}} { + path := createSynced(t, account.store, account.profile) + for _, body := range []string{`{"sync_schedule":"30 4 * * *"}`, `{"sync_schedule":"*/15 * * * *"}`, `{"sync_schedule":null}`} { + t.Run(name+" "+body, func(t *testing.T) { requireRefused(t, path, body, account.headers) }) + } + requireSchedule(t, path, account.headers, daily, "daily", true) + } + }) + + t.Run("manual and smart collections take no schedule", func(t *testing.T) { + for _, body := range []string{`{"name":"Manual","collection_type":"manual"}`, `{"name":"Smart","collection_type":"smart","query_definition":{"match":"all","groups":[]}}`} { + path := guardHTTPCreate(t, h, body) + requireRefused(t, path, `{"sync_schedule":"daily"}`, memberHeaders) + requireSchedule(t, path, memberHeaders, "", "", false) + } + }) + + t.Run("capabilities report the schedule as editable", func(t *testing.T) { + rec := do(t, h, http.MethodGet, "/api/v2/collections/capabilities", "", memberHeaders) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"sync_schedule_editable":true`) { + t.Fatalf("%d %s", rec.Code, rec.Body.String()) + } + }) +} diff --git a/internal/apiv2/personal_collections_test.go b/internal/apiv2/personal_collections_test.go index 414bd7557f..645014562b 100644 --- a/internal/apiv2/personal_collections_test.go +++ b/internal/apiv2/personal_collections_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "slices" "strings" @@ -14,6 +15,7 @@ import ( "github.com/Silo-Server/silo-server/internal/collections/templates" "github.com/Silo-Server/silo-server/internal/mdblist" "github.com/Silo-Server/silo-server/internal/usercollections" + "github.com/Silo-Server/silo-server/internal/userstore" ) // fakePersonalCollections records the last command and answers fixtures. @@ -22,9 +24,18 @@ type fakePersonalCollections struct { list handlers.PersonalCollectionListView lastCreate handlers.PersonalCollectionCreateCommand lastOrder []string - lastGroup *string - lastReq handlers.CollectionGroupUpdateRequest - lastID string + // holding answers PersonalCollectionsHoldingItem; holdingCalls records + // each call's profile and item. + holding map[string]bool + holdingErr error + holdingCalls []string + features userstore.CollectionFeatures + // collages answers Capabilities' PosterCollages. + collages bool +} + +func (f *fakePersonalCollections) PersonalCollectionFeatures(context.Context, int) (userstore.CollectionFeatures, error) { + return f.features, nil } func (f *fakePersonalCollections) ListPersonalCollections(_ context.Context, _ int, profileID string) (handlers.PersonalCollectionListView, error) { @@ -37,12 +48,21 @@ func (f *fakePersonalCollections) ListPersonalCollections(_ context.Context, _ i return f.list, nil } +func (f *fakePersonalCollections) PersonalCollectionsHoldingItem(_ context.Context, _ int, profileID, itemID string) (map[string]bool, error) { + f.holdingCalls = append(f.holdingCalls, profileID+"/"+itemID) + if f.holdingErr != nil { + return nil, f.holdingErr + } + return f.holding, nil +} + func (f *fakePersonalCollections) Capabilities() handlers.CollectionCapabilitiesView { return handlers.CollectionCapabilitiesView{ DisplayFilterFields: []string{"type", "watched"}, DisplayFilterPresets: handlers.CollectionDisplayFilterPresetsView{Watched: []string{"all", "watched", "unwatched"}, Media: []string{"all", "movie", "series"}}, CollectionDefaultSort: true, CollectionSortPreferences: true, EffectiveCollectionSort: true, SortPreferenceKinds: []string{"library", "user", "watchlist", "favorites"}, + PosterCollages: f.collages, } } @@ -56,52 +76,43 @@ func (f *fakePersonalCollections) CreatePersonalCollection(_ context.Context, cm } v := fixtureCollectionView() v.Name = cmd.Request.Name + v.Description = cmd.Request.Description v.CollectionType = cmd.Request.CollectionType - v.AllowedProfileIDs = cmd.Request.AllowedProfileIDs + v.IsShared = cmd.Request.IsShared return v, nil } -func (f *fakePersonalCollections) ReorderPersonalCollections(_ context.Context, _ int, _ string, groupID *string, orderedIDs []string) error { - f.lastGroup, f.lastOrder = groupID, orderedIDs +// ReorderPersonalCollections answers like the seam: ordered_ids must name +// each of the acting profile's own collections (here c1) exactly once. +func (f *fakePersonalCollections) ReorderPersonalCollections(_ context.Context, _ int, _ string, orderedIDs []string) error { + f.lastOrder = orderedIDs if f.err != nil { return f.err } - if len(orderedIDs) == 0 { - return &handlers.APIError{Status: 400, Code: "bad_request", Message: "ordered_ids must include every visible collection in the group exactly once", Field: "ordered_ids"} + if !slices.Equal(orderedIDs, []string{"c1"}) { + return &handlers.APIError{Status: 400, Code: "bad_request", Message: "ordered_ids must name each of your own collections exactly once", Field: "ordered_ids"} } return nil } -func (f *fakePersonalCollections) CreateCollectionGroup(_ context.Context, _ int, req handlers.CollectionGroupCreateRequest) (handlers.CollectionGroupView, error) { - if f.err != nil { - return handlers.CollectionGroupView{}, f.err - } - return handlers.CollectionGroupView{ID: "g1", Name: req.Name, Slug: "seasonal", DefaultSortMode: "manual"}, nil +// errGroupsUnsupported is how the seams answer every personal collection +// group operation since #1615. +var errGroupsUnsupported = &handlers.APIError{Status: http.StatusNotImplemented, Code: "unsupported", Message: "The acting account does not support collection groups"} + +func (f *fakePersonalCollections) CreateCollectionGroup(context.Context, int, handlers.CollectionGroupCreateRequest) (handlers.CollectionGroupView, error) { + return handlers.CollectionGroupView{}, errGroupsUnsupported } -func (f *fakePersonalCollections) UpdateCollectionGroup(_ context.Context, _ int, id string, req handlers.CollectionGroupUpdateRequest) (handlers.CollectionGroupView, error) { - f.lastID, f.lastReq = id, req - if f.err != nil { - return handlers.CollectionGroupView{}, f.err - } - if id != "g1" { - return handlers.CollectionGroupView{}, &handlers.APIError{Status: 400, Code: "bad_request", Message: "collection group not found"} - } - g := handlers.CollectionGroupView{ID: "g1", Name: "Seasonal", Slug: "seasonal", DefaultSortMode: "manual", SortOrder: 1} - if req.Name != nil { - g.Name = *req.Name - } - return g, nil +func (f *fakePersonalCollections) UpdateCollectionGroup(context.Context, int, string, handlers.CollectionGroupUpdateRequest) (handlers.CollectionGroupView, error) { + return handlers.CollectionGroupView{}, errGroupsUnsupported } -func (f *fakePersonalCollections) DeleteCollectionGroup(_ context.Context, _ int, id string) error { - f.lastID = id - return f.err +func (f *fakePersonalCollections) DeleteCollectionGroup(context.Context, int, string) error { + return errGroupsUnsupported } -func (f *fakePersonalCollections) ReorderCollectionGroups(_ context.Context, _ int, orderedIDs []string) error { - f.lastOrder = orderedIDs - return f.err +func (f *fakePersonalCollections) ReorderCollectionGroups(context.Context, int, []string) error { + return errGroupsUnsupported } // fakeCollectionImports answers one imported collection and one search. @@ -169,7 +180,7 @@ func fixtureCollectionView() handlers.PersonalCollectionView { stamp := fixedTime().Format(time.RFC3339Nano) return handlers.PersonalCollectionView{ ID: "c1", ProfileID: "p-owner", CreatorProfileID: "p-owner", Name: "Rainy days", CollectionType: "manual", - AllowedProfileIDs: []string{}, QueryDefinition: json.RawMessage(`{}`), SortConfig: json.RawMessage(`{}`), + QueryDefinition: json.RawMessage(`{}`), SortConfig: json.RawMessage(`{}`), ItemCount: 4, CreatedAt: stamp, UpdatedAt: stamp, } } @@ -177,12 +188,12 @@ func fixtureCollectionView() handlers.PersonalCollectionView { func collectionDeps(t *testing.T) (Dependencies, *fakePersonalCollections, *fakeCollectionImports) { t.Helper() deps := pilotDeps(nil, nil) - group := "g1" - grouped := fixtureCollectionView() - grouped.ID, grouped.GroupID, grouped.SortOrder = "c2", &group, 1 + // The acting profile's own collection, then another profile's shared one. + shared := fixtureCollectionView() + shared.ID, shared.ProfileID, shared.CreatorProfileID, shared.Name, shared.IsShared = "c2", "p-primary", "p-primary", "Family night", true pc := &fakePersonalCollections{list: handlers.PersonalCollectionListView{ - Collections: []handlers.PersonalCollectionView{fixtureCollectionView(), grouped}, - Groups: []handlers.CollectionGroupView{{ID: "g1", Name: "Seasonal", Slug: "seasonal", DefaultSortMode: "manual"}}, + Collections: []handlers.PersonalCollectionView{fixtureCollectionView(), shared}, + Groups: []handlers.CollectionGroupView{}, }} ci := &fakeCollectionImports{configured: true} deps.PersonalCollections = pc @@ -191,18 +202,24 @@ func collectionDeps(t *testing.T) (Dependencies, *fakePersonalCollections, *fake } func TestListCollections(t *testing.T) { - deps, _, _ := collectionDeps(t) + deps, pc, _ := collectionDeps(t) h := newTestHandler(t, deps) rec := do(t, h, http.MethodGet, "/api/v2/collections", "", viewerHeaders()) if rec.Code != 200 { t.Fatal(rec.Body.String()) } - want := `{"items":[{"id":"c1","profile_id":"p-owner","creator_profile_id":"p-owner","name":"Rainy days","description":"","collection_type":"manual","is_shared":false,"allowed_profile_ids":[],"query_definition":{},"sort_config":{},"sort_order":0,"group_id":null,"source_url":"","sync_schedule":"","next_sync_at":null,"last_sync_at":null,"last_sync_status":"","last_sync_message":"","item_count":4,"include_in_server_collections":false,"poster_url":"","poster_thumbhash":"","created_at":"2026-01-02T03:04:05.678Z","updated_at":"2026-01-02T03:04:05.678Z"},` + - `{"id":"c2","profile_id":"p-owner","creator_profile_id":"p-owner","name":"Rainy days","description":"","collection_type":"manual","is_shared":false,"allowed_profile_ids":[],"query_definition":{},"sort_config":{},"sort_order":1,"group_id":"g1","source_url":"","sync_schedule":"","next_sync_at":null,"last_sync_at":null,"last_sync_status":"","last_sync_message":"","item_count":4,"include_in_server_collections":false,"poster_url":"","poster_thumbhash":"","created_at":"2026-01-02T03:04:05.678Z","updated_at":"2026-01-02T03:04:05.678Z"}],` + - `"groups":[{"id":"g1","name":"Seasonal","slug":"seasonal","default_sort_mode":"manual","sort_order":0}]}` + "\n" + want := `{"items":[{"id":"c1","profile_id":"p-owner","creator_profile_id":"p-owner","name":"Rainy days","description":"","collection_type":"manual","is_shared":false,"query_definition":{},"sort_config":{},"sort_order":0,"group_id":null,"source_url":"","sync_schedule":"","sync_cadence":"","next_sync_at":null,"last_sync_at":null,"last_sync_status":"","last_sync_message":"","item_count":4,"include_in_server_collections":false,"poster_url":"","poster_thumbhash":"","poster_is_collage":false,"created_at":"2026-01-02T03:04:05.678Z","updated_at":"2026-01-02T03:04:05.678Z"},` + + `{"id":"c2","profile_id":"p-primary","creator_profile_id":"p-primary","name":"Family night","description":"","collection_type":"manual","is_shared":true,"query_definition":{},"sort_config":{},"sort_order":0,"group_id":null,"source_url":"","sync_schedule":"","sync_cadence":"","next_sync_at":null,"last_sync_at":null,"last_sync_status":"","last_sync_message":"","item_count":4,"include_in_server_collections":false,"poster_url":"","poster_thumbhash":"","poster_is_collage":false,"created_at":"2026-01-02T03:04:05.678Z","updated_at":"2026-01-02T03:04:05.678Z"}],` + + `"groups":[]}` + "\n" if rec.Body.String() != want { t.Fatalf("body = %s", rec.Body.String()) } + // A collage poster is marked as one. + pc.list.Collections[0].PosterURL, pc.list.Collections[0].PosterThumbhash, pc.list.Collections[0].PosterIsCollage = "https://cdn.test/collage.webp", "th", true + rec = do(t, h, http.MethodGet, "/api/v2/collections", "", viewerHeaders()) + if !strings.Contains(rec.Body.String(), `"poster_url":"https://cdn.test/collage.webp","poster_thumbhash":"th","poster_is_collage":true`) { + t.Fatalf("collage body = %s", rec.Body.String()) + } // Another profile sees an empty, never-null envelope. rec = do(t, h, http.MethodGet, "/api/v2/collections", "", with(bearer(memberToken), "X-Profile-Id", "p-primary")) if rec.Code != 200 || rec.Body.String() != `{"items":[],"groups":[]}`+"\n" { @@ -223,34 +240,67 @@ func TestListCollections(t *testing.T) { } func TestGetCollectionCapabilities(t *testing.T) { - deps, _, _ := collectionDeps(t) + deps, pc, _ := collectionDeps(t) + deps.ScheduleZone = fixtureScheduleTimeZone + pc.features.Description = true rec := do(t, newTestHandler(t, deps), http.MethodGet, "/api/v2/collections/capabilities", "", viewerHeaders()) if rec.Code != 200 { t.Fatal(rec.Body.String()) } - want := `{"groups":false,"imports":false,"import_sources":[],"artwork":false,"item_reorder":false,"display_filter_fields":["type","watched"],"display_filter_presets":{"watched":["all","watched","unwatched"],"media":["all","movie","series"]},"collection_default_sort":true,"collection_sort_preferences":true,"effective_collection_sort":true,"sort_preference_kinds":["library","user","watchlist","favorites"]}` + "\n" + want := `{"groups":false,"login_sharing":true,"imports":false,"import_sources":[],"artwork":false,"item_reorder":false,"display_filter_fields":["type","watched"],"display_filter_presets":{"watched":["all","watched","unwatched"],"media":["all","movie","series"]},"collection_default_sort":true,"collection_sort_preferences":true,"effective_collection_sort":true,"sort_preference_kinds":["library","user","watchlist","favorites"],"create_description":true,"mdblist_search":true,"schedule_time_zone":{"utc_offset":"-05:00","abbreviation":"CDT","name":"America/Chicago"},"sync_schedule_editable":false,"contains_item":true,"preview_posters":true,"poster_collages":false}` + "\n" if !capabilityBodyMatches(t, rec.Body.Bytes(), want) { t.Fatalf("body = %s", rec.Body.String()) } + // Collages need both the server's collage support and the account's artwork. + pc.collages = true + for _, artwork := range []bool{false, true} { + pc.features.Artwork = artwork + rec = do(t, newTestHandler(t, deps), http.MethodGet, "/api/v2/collections/capabilities", "", viewerHeaders()) + if want := fmt.Sprintf(`"poster_collages":%t`, artwork); !strings.Contains(rec.Body.String(), want) { + t.Fatalf("artwork %v: body = %s, want %s", artwork, rec.Body.String(), want) + } + } + pc.collages, pc.features.Artwork = false, false + // A store that does not persist descriptions does not advertise them. + pc.features.Description = false + rec = do(t, newTestHandler(t, deps), http.MethodGet, "/api/v2/collections/capabilities", "", viewerHeaders()) + if rec.Code != 200 || !strings.Contains(rec.Body.String(), `"create_description":false`) { + t.Fatalf("%d %s", rec.Code, rec.Body.String()) + } } func TestCreateCollection(t *testing.T) { deps, pc, _ := collectionDeps(t) h := newTestHandler(t, deps) - rec := do(t, h, http.MethodPost, "/api/v2/collections", `{"name":"Smart","collection_type":"smart","query_definition":{"filters":[]},"allowed_profile_ids":["p-primary"],"is_shared":true}`, viewerHeaders()) + rec := do(t, h, http.MethodPost, "/api/v2/collections", `{"name":"Smart","collection_type":"smart","query_definition":{"filters":[]},"is_shared":true}`, viewerHeaders()) if rec.Code != 201 || rec.Header().Get("Location") != "/api/v2/collections/c1" { t.Fatalf("%d %s %s", rec.Code, rec.Header().Get("Location"), rec.Body.String()) } - if !strings.Contains(rec.Body.String(), `"name":"Smart","description":"","collection_type":"smart"`) || !strings.Contains(rec.Body.String(), `"allowed_profile_ids":["p-primary"]`) { + if !strings.Contains(rec.Body.String(), `"name":"Smart","description":"","collection_type":"smart","is_shared":true,`) || strings.Contains(rec.Body.String(), "allowed_profile_ids") { t.Fatalf("body = %s", rec.Body.String()) } cmd := pc.lastCreate if cmd.UserID != 1 || cmd.ProfileID != "p-owner" || cmd.PosterFile != nil || !cmd.Request.IsShared || string(cmd.Request.QueryDefinition) != `{"filters":[]}` { t.Fatalf("command = %+v", cmd) } + if cmd.Request.Description != "" { + t.Fatalf("description without one in the body = %q, want empty", cmd.Request.Description) + } + // A description is stored with the new collection and echoed back. + rec = do(t, h, http.MethodPost, "/api/v2/collections", `{"name":"Rainy days","description":"For wet afternoons"}`, viewerHeaders()) + if rec.Code != 201 || !strings.Contains(rec.Body.String(), `"name":"Rainy days","description":"For wet afternoons"`) { + t.Fatalf("%d %s", rec.Code, rec.Body.String()) + } + if got := pc.lastCreate.Request.Description; got != "For wet afternoons" { + t.Fatalf("description = %q", got) + } + p := requireProblem(t, do(t, h, http.MethodPost, "/api/v2/collections", `{"name":"x","description":null}`, viewerHeaders()), TypeValidationFailed) + if len(p.Errors) != 1 || p.Errors[0].Location != "body.description" || p.Errors[0].Code != codeInvalidType { + t.Fatalf("errors = %+v", p.Errors) + } // Validation: the schema (missing name), the seam (empty name), an // unknown enum, and null on a non-nullable member. - p := requireProblem(t, do(t, h, http.MethodPost, "/api/v2/collections", `{"collection_type":"manual"}`, viewerHeaders()), TypeValidationFailed) + p = requireProblem(t, do(t, h, http.MethodPost, "/api/v2/collections", `{"collection_type":"manual"}`, viewerHeaders()), TypeValidationFailed) if len(p.Errors) != 1 || p.Errors[0].Location != "body.name" || p.Errors[0].Code != codeRequired { t.Fatalf("errors = %+v", p.Errors) } @@ -260,6 +310,8 @@ func TestCreateCollection(t *testing.T) { t.Fatalf("errors = %+v", p.Errors) } requireProblem(t, do(t, h, http.MethodPost, "/api/v2/collections", `{"name":"x","extra":1}`, viewerHeaders()), TypeValidationFailed) + // The per-profile allow list is no longer a member. + requireProblem(t, do(t, h, http.MethodPost, "/api/v2/collections", `{"name":"x","is_shared":true,"allowed_profile_ids":["p-primary"]}`, viewerHeaders()), TypeValidationFailed) // The class and demo mode. requireProblem(t, do(t, h, http.MethodPost, "/api/v2/collections", `{"name":"x"}`, bearer(memberToken)), TypeValidationFailed) demo := deps @@ -270,58 +322,55 @@ func TestCreateCollection(t *testing.T) { func TestReorderCollections(t *testing.T) { deps, pc, _ := collectionDeps(t) h := newTestHandler(t, deps) - rec := do(t, h, http.MethodPut, "/api/v2/collections/order", `{"group_id":"g1","ordered_ids":["c2"]}`, with(viewerHeaders(), "If-Match", "*")) - if rec.Code != 200 || pc.lastGroup == nil || *pc.lastGroup != "g1" || len(pc.lastOrder) != 1 || pc.lastOrder[0] != "c2" { - t.Fatalf("%d %v %v", rec.Code, pc.lastGroup, pc.lastOrder) - } - // Null and omitted group both target the ungrouped section. + // Null and omitted group both mean the profile's one flat order. for _, body := range []string{`{"group_id":null,"ordered_ids":["c1"]}`, `{"ordered_ids":["c1"]}`} { - if rec := do(t, h, http.MethodPut, "/api/v2/collections/order", body, with(viewerHeaders(), "If-Match", "*")); rec.Code != 200 || pc.lastGroup != nil { - t.Fatalf("%s: %d %v", body, rec.Code, pc.lastGroup) + if rec := do(t, h, http.MethodPut, "/api/v2/collections/order", body, with(viewerHeaders(), "If-Match", "*")); rec.Code != 200 || !slices.Equal(pc.lastOrder, []string{"c1"}) { + t.Fatalf("%s: %d %v %s", body, rec.Code, pc.lastOrder, rec.Body.String()) } } - // The seam's mismatch is a validation problem at ordered_ids. - p := requireProblem(t, do(t, h, http.MethodPut, "/api/v2/collections/order", `{"ordered_ids":[]}`, with(viewerHeaders(), "If-Match", "*")), TypeValidationFailed) - if len(p.Errors) != 1 || p.Errors[0].Location != "body.ordered_ids" { + // Personal collection groups are gone: a group scope is refused before + // the seam, in the body and in the order read. + pc.lastOrder = nil + p := requireProblem(t, do(t, h, http.MethodPut, "/api/v2/collections/order", `{"group_id":"g1","ordered_ids":["c1"]}`, with(viewerHeaders(), "If-Match", "*")), TypeValidationFailed) + if len(p.Errors) != 1 || p.Errors[0].Location != "body.group_id" || pc.lastOrder != nil { + t.Fatalf("errors = %+v, seam called with %v", p.Errors, pc.lastOrder) + } + p = requireProblem(t, do(t, h, http.MethodGet, "/api/v2/collections/order?group_id=g1", "", viewerHeaders()), TypeValidationFailed) + if len(p.Errors) != 1 || p.Errors[0].Location != "query.group_id" { t.Fatalf("errors = %+v", p.Errors) } + // Another profile's collection, even a shared one the profile can see, is + // not part of its order (D11): a validation problem at ordered_ids. + for _, body := range []string{`{"ordered_ids":["c2","c1"]}`, `{"ordered_ids":[]}`} { + p = requireProblem(t, do(t, h, http.MethodPut, "/api/v2/collections/order", body, with(viewerHeaders(), "If-Match", "*")), TypeValidationFailed) + if len(p.Errors) != 1 || p.Errors[0].Location != "body.ordered_ids" { + t.Fatalf("%s: errors = %+v", body, p.Errors) + } + } requireProblem(t, do(t, h, http.MethodPost, "/api/v2/collections/order", `{"ordered_ids":["c1"]}`, with(viewerHeaders(), "If-Match", "*")), TypeMethodNotAllowed) requireProblem(t, do(t, h, http.MethodPut, "/api/v2/collections/order", `{"ordered_ids":["c1"]}`, nil), TypeAuthenticationRequired) } -func TestCollectionGroups(t *testing.T) { - deps, pc, _ := collectionDeps(t) +// Every personal collection group operation answers capability_unsupported, +// as it already did for an account on the SQLite store. +func TestCollectionGroupsAreUnsupported(t *testing.T) { + deps, _, _ := collectionDeps(t) h := newTestHandler(t, deps) - rec := do(t, h, http.MethodPost, "/api/v2/collections/groups", `{"name":"Seasonal","default_sort_mode":"manual"}`, with(viewerHeaders(), "If-Match", "*")) - if rec.Code != 201 || rec.Header().Get("Location") != "/api/v2/collections/groups/g1" { - t.Fatalf("%d %s %s", rec.Code, rec.Header().Get("Location"), rec.Body.String()) - } - if want := `{"id":"g1","name":"Seasonal","slug":"seasonal","default_sort_mode":"manual","sort_order":0}` + "\n"; rec.Body.String() != want { - t.Fatalf("body = %s", rec.Body.String()) - } - requireProblem(t, do(t, h, http.MethodPost, "/api/v2/collections/groups", `{"name":"x","default_sort_mode":"random"}`, with(viewerHeaders(), "If-Match", "*")), TypeValidationFailed) - - rec = do(t, h, http.MethodPatch, "/api/v2/collections/groups/g1", `{"name":"Winter"}`, with(viewerHeaders(), "If-Match", "*")) - if rec.Code != 200 || pc.lastID != "g1" || pc.lastReq.Name == nil || *pc.lastReq.Name != "Winter" || pc.lastReq.Slug != nil { - t.Fatalf("%d %s %+v", rec.Code, rec.Body.String(), pc.lastReq) - } - p := requireProblem(t, do(t, h, http.MethodPatch, "/api/v2/collections/groups/g1", `{"slug":null}`, with(viewerHeaders(), "If-Match", "*")), TypeValidationFailed) - if len(p.Errors) != 1 || p.Errors[0].Location != "body.slug" { - t.Fatalf("errors = %+v", p.Errors) - } - // v1 answers an unknown group with 400; that is a validation problem. - requireProblem(t, do(t, h, http.MethodPatch, "/api/v2/collections/groups/g9", `{"name":"x"}`, with(viewerHeaders(), "If-Match", "*")), TypeNotFound) - - if rec := do(t, h, http.MethodPut, "/api/v2/collections/groups/order", `{"ordered_ids":["g1"]}`, with(viewerHeaders(), "If-Match", "*")); rec.Code != 200 || len(pc.lastOrder) != 1 || pc.lastOrder[0] != "g1" { - t.Fatalf("%d %v", rec.Code, pc.lastOrder) - } - if rec := do(t, h, http.MethodDelete, "/api/v2/collections/groups/g1", "", with(viewerHeaders(), "If-Match", "*")); rec.Code != 204 || pc.lastID != "g1" { - t.Fatalf("%d %s", rec.Code, pc.lastID) + for _, req := range []struct{ method, path, body string }{ + {http.MethodPost, "/api/v2/collections/groups", `{"name":"Seasonal"}`}, + {http.MethodGet, "/api/v2/collections/groups/g1", ""}, + {http.MethodPatch, "/api/v2/collections/groups/g1", `{"name":"Winter"}`}, + {http.MethodDelete, "/api/v2/collections/groups/g1", ""}, + {http.MethodGet, "/api/v2/collections/groups/order", ""}, + {http.MethodPut, "/api/v2/collections/groups/order", `{"ordered_ids":["g1"]}`}, + } { + rec := do(t, h, req.method, req.path, req.body, with(viewerHeaders(), "If-Match", "*")) + if rec.Code != http.StatusNotImplemented { + t.Errorf("%s %s: %d %s", req.method, req.path, rec.Code, rec.Body.String()) + continue + } + requireProblem(t, rec, TypeCapabilityUnsupported) } - demo := deps - demo.DemoSettings = fakeSettings{demo: true} - requireProblem(t, do(t, newTestHandler(t, demo), http.MethodDelete, "/api/v2/collections/groups/g1", "", with(viewerHeaders(), "If-Match", "*")), TypePermissionDenied) - requireProblem(t, do(t, h, http.MethodDelete, "/api/v2/collections/groups/g1", "", bearer(memberToken)), TypeValidationFailed) } func TestImportCollections(t *testing.T) { @@ -424,21 +473,14 @@ func TestCollectionProblemPreservesUnsupportedSource(t *testing.T) { func (f *fakePersonalCollections) PersonalCollectionEditor(_ context.Context, _ int, _ string, id string) (handlers.PersonalCollectionEditorView, error) { return handlers.PersonalCollectionEditorView{Collection: fixtureCollectionView(), Revision: 1}, f.err } -func (f *fakePersonalCollections) PersonalCollectionOrderEditor(_ context.Context, _ int, _ string, g *string) (handlers.PersonalCollectionOrderView, error) { - return handlers.PersonalCollectionOrderView{GroupID: g, OrderedIDs: f.lastOrder, Revision: 1}, f.err +func (f *fakePersonalCollections) PersonalCollectionOrderEditor(context.Context, int, string) (handlers.PersonalCollectionOrderView, error) { + return handlers.PersonalCollectionOrderView{OrderedIDs: f.lastOrder, Revision: 1}, f.err } -func (f *fakePersonalCollections) PersonalCollectionGroupsEditor(_ context.Context, _ int) ([]handlers.CollectionGroupView, int64, error) { - return []handlers.CollectionGroupView{{ID: "g1", Name: "Seasonal", Slug: "seasonal", DefaultSortMode: "manual"}}, 1, f.err +func (f *fakePersonalCollections) PersonalCollectionGroupsEditor(context.Context, int) ([]handlers.CollectionGroupView, int64, error) { + return nil, 0, errGroupsUnsupported } -func (f *fakePersonalCollections) PersonalCollectionGroupEditor(_ context.Context, _ int, id string) (handlers.PersonalCollectionGroupEditorView, error) { - if id != "g1" { - return handlers.PersonalCollectionGroupEditorView{}, &handlers.APIError{Status: 404, Code: "not_found", Message: "Collection group not found"} - } - name := "Seasonal" - if f.lastReq.Name != nil { - name = *f.lastReq.Name - } - return handlers.PersonalCollectionGroupEditorView{Group: handlers.CollectionGroupView{ID: id, Name: name, Slug: "seasonal", DefaultSortMode: "manual"}, Revision: 1}, f.err +func (f *fakePersonalCollections) PersonalCollectionGroupEditor(context.Context, int, string) (handlers.PersonalCollectionGroupEditorView, error) { + return handlers.PersonalCollectionGroupEditorView{}, errGroupsUnsupported } func (f *fakePersonalCollections) PersonalCollectionItemsOrderEditor(_ context.Context, _ int, _ string, id string) (handlers.PersonalCollectionOrderView, error) { return handlers.PersonalCollectionOrderView{OrderedIDs: []string{}, Revision: 1}, f.err @@ -462,7 +504,7 @@ func TestImportableCollectionTemplatesKeepsPersonalSources(t *testing.T) { if len(want) == 0 || !hasExcludedSource { t.Fatal("built-in catalog must contain both importable and excluded sources") } - got := importableCollectionTemplates(full) + got := creatableCollectionTemplates(full) kept := map[templates.Source]int{} for _, group := range got.Categories { @@ -484,3 +526,14 @@ func TestImportableCollectionTemplatesKeepsPersonalSources(t *testing.T) { } } } + +// poster_is_collage is false whenever poster_url is empty, as on the +// editor state getCollection answers. +func TestPersonalCollectionMarksCollageOnlyWithPosterURL(t *testing.T) { + if got := personalCollectionOf(handlers.PersonalCollectionView{ID: "c1", PosterIsCollage: true}); got.PosterIsCollage { + t.Fatalf("collection without poster_url = %+v, want no poster_is_collage", got) + } + if got := personalCollectionOf(handlers.PersonalCollectionView{ID: "c1", PosterURL: "https://cdn.test/collage.webp", PosterIsCollage: true}); !got.PosterIsCollage { + t.Fatalf("collection with a collage = %+v, want poster_is_collage", got) + } +} diff --git a/internal/apiv2/pilot_fakes_test.go b/internal/apiv2/pilot_fakes_test.go index d866777085..52bd818e12 100644 --- a/internal/apiv2/pilot_fakes_test.go +++ b/internal/apiv2/pilot_fakes_test.go @@ -475,7 +475,6 @@ func pilotDeps(progress *fakeProgress, profiles *fakeProfiles) Dependencies { deps.Profiles = profiles deps.Libraries = fakeLibraries{known: []int{1, 2, 3, 4}} deps.ProfileSections = &fakeProfileSections{rows: fixtureSectionOverrides()} - deps.SectionFlags = fakeSectionFlags{allow: true} two, yes := 2, true groupID := int64(2) last := fixedTime() @@ -973,15 +972,11 @@ func (f *fakeProfileSections) ResolveProfileSectionSettings(_ context.Context, u return nil, f.err } return []sections.ResolvedSection{ - {ID: "s-continue", SectionType: "continue_watching", Title: "Continue Watching", ItemLimit: 20, Position: 0, Customized: true, Hidden: true}, + {ID: "s-continue", SectionType: "continue_watching", Title: "Keep watching", DefaultTitle: "Continue Watching", ItemLimit: 20, Position: 0, Customized: true, Hidden: true}, {ID: "u-gems", SectionType: "hidden_gems", Title: "Hidden gems", ItemLimit: 12, Position: 1, IsCustom: true, Config: json.RawMessage(`{"library_ids":[3]}`)}, }, nil } -type fakeSectionFlags struct{ allow bool } - -func (f fakeSectionFlags) AllowProfileCustomSections(context.Context) bool { return f.allow } - func fixtureSectionOverrides() []userstore.SectionOverride { pos, featured, limit := 2, false, 10 return []userstore.SectionOverride{ diff --git a/internal/apiv2/profile_sections.go b/internal/apiv2/profile_sections.go index 36b38843e5..1a4bd0b963 100644 --- a/internal/apiv2/profile_sections.go +++ b/internal/apiv2/profile_sections.go @@ -116,16 +116,17 @@ type SectionOverridesReplaceInput struct { // the customization screen: the admin definition with the profile's // overrides applied. type ProfileSectionSetting struct { - ID string `json:"id" example:"s-continue-watching"` - SectionType string `json:"section_type" doc:"The recipe; the set is extensible" example:"continue_watching"` - Title string `json:"title" example:"Continue Watching"` - Featured bool `json:"featured" example:"false"` - ItemLimit int `json:"item_limit" example:"20"` - Hidden bool `json:"hidden" doc:"Hidden by the profile" example:"false"` - IsCustom bool `json:"is_custom" doc:"Built by the profile rather than defined by an administrator" example:"false"` - Customized bool `json:"customized" doc:"An admin section the profile has changed" example:"true"` - Position int `json:"position" example:"0"` - Config SectionConfig `json:"config,omitzero" doc:"The recipe's effective config; absent when none, {} when explicitly empty"` + ID string `json:"id" example:"s-continue-watching"` + SectionType string `json:"section_type" doc:"The recipe; the set is extensible" example:"continue_watching"` + Title string `json:"title" doc:"The title this profile sees: its own title override, or the administrator's title" example:"Continue Watching"` + DefaultTitle string `json:"default_title" doc:"The administrator's title for this row; title shows it unless the profile saved a title override. Empty for a section the profile built" example:"Continue Watching"` + Featured bool `json:"featured" example:"false"` + ItemLimit int `json:"item_limit" example:"20"` + Hidden bool `json:"hidden" doc:"Hidden by the profile" example:"false"` + IsCustom bool `json:"is_custom" doc:"Built by the profile rather than defined by an administrator" example:"false"` + Customized bool `json:"customized" doc:"An admin section the profile has changed" example:"true"` + Position int `json:"position" example:"0"` + Config SectionConfig `json:"config,omitzero" doc:"The recipe's effective config; absent when none, {} when explicitly empty"` } // ProfileSectionSettingCollection is the getProfileSectionSettings envelope. @@ -140,7 +141,7 @@ type ProfileSectionSettingCollectionOutput struct { // ProfileSectionFlags is what this server lets profiles do to their pages. type ProfileSectionFlags struct { - AllowProfileCustomSections bool `json:"allow_profile_custom_sections" doc:"Whether non-admin profiles may build sections from admin-only recipes" example:"false"` + AllowProfileCustomSections bool `json:"allow_profile_custom_sections" doc:"Deprecated; always true. Profiles may always add rule rows (custom_filter). Whether a profile that is not an admin may add a new row of a recipe is that recipe's admin_only in listSectionRecipes; follow it instead of this flag. Kept for clients that still read it." example:"true" deprecated:"true"` } // ProfileSectionFlagsOutput is the getProfileSectionFlags response. @@ -174,9 +175,9 @@ func registerProfileSections(reg *Registry) { replace := humaOp(http.MethodPut, Prefix+"/profile/sections", "replaceProfileSectionOverrides", "profile_sections", "Replace the acting profile's section overrides for one page.") - // v1 refuses a profile-built section of an admin-only recipe with 403 - // custom_disabled unless the server allows it; the status is this - // operation's own, not one the class implies. + // A profile that is not an admin adding a new section of an admin-only + // recipe (Editor's picks) is refused with 403 custom_disabled, as on v1; + // the status is this operation's own, not one the class implies. replace.Errors = []int{http.StatusForbidden} Register(reg, Operation{ Operation: replace, @@ -205,8 +206,7 @@ func registerProfileSections(reg *Registry) { Register(reg, Operation{ Operation: humaOp(http.MethodGet, Prefix+"/profile/sections/flags", "getProfileSectionFlags", "profile_sections", "Get what this server lets profiles do to their pages."), - Class: ClassProfileScoped, - ServiceBacked: true, + Class: ClassProfileScoped, }, reg.getProfileSectionFlags) } @@ -357,28 +357,26 @@ func profileSectionSettingsOf(resolved []sections.ResolvedSection) (*ProfileSect return nil, p } items = append(items, ProfileSectionSetting{ - ID: s.ID, - SectionType: string(s.SectionType), - Title: s.Title, - Featured: s.Featured, - ItemLimit: s.ItemLimit, - Hidden: s.Hidden, - IsCustom: s.IsCustom, - Customized: s.Customized, - Position: s.Position, - Config: config, + ID: s.ID, + SectionType: string(s.SectionType), + Title: s.Title, + DefaultTitle: s.DefaultTitle, + Featured: s.Featured, + ItemLimit: s.ItemLimit, + Hidden: s.Hidden, + IsCustom: s.IsCustom, + Customized: s.Customized, + Position: s.Position, + Config: config, }) } return &ProfileSectionSettingCollectionOutput{Body: ProfileSectionSettingCollection{Collection: NewCollection(items)}}, nil } -// getProfileSectionFlags reads the same setting v1 GET /profile/sections/flags -// does. -func (reg *Registry) getProfileSectionFlags(ctx context.Context, _ *struct{}) (*ProfileSectionFlagsOutput, error) { - if reg.deps.SectionFlags == nil { - return nil, unavailable("section") - } - return &ProfileSectionFlagsOutput{Body: ProfileSectionFlags{AllowProfileCustomSections: reg.deps.SectionFlags.AllowProfileCustomSections(ctx)}}, nil +// getProfileSectionFlags answers what v1 GET /profile/sections/flags does: +// profiles may always add rule rows, so the flag is always true. +func (reg *Registry) getProfileSectionFlags(context.Context, *struct{}) (*ProfileSectionFlagsOutput, error) { + return &ProfileSectionFlagsOutput{Body: ProfileSectionFlags{AllowProfileCustomSections: true}}, nil } // sectionProblem maps the v1 decision onto problem types: a rejected recipe @@ -389,9 +387,13 @@ func sectionProblem(err error) *Problem { if !ok { return serviceProblem(err) } - if apiErr.Status == http.StatusBadRequest { + switch { + case apiErr.Status == http.StatusBadRequest: return NewProblem(TypeValidationFailed, "The request did not pass validation; see errors."). WithErrors(ProblemError{Location: locationOverrides, Code: codeInvalid, Detail: apiErr.Message}) + case apiErr.Code == "custom_disabled": + // v1 keeps the message from when a server setting decided this. + return NewProblem(TypePermissionDenied, "Only an admin can add an Editor's picks row.") } return serviceProblem(err) } diff --git a/internal/apiv2/profile_sections_test.go b/internal/apiv2/profile_sections_test.go index 1b781e4996..7d284ce653 100644 --- a/internal/apiv2/profile_sections_test.go +++ b/internal/apiv2/profile_sections_test.go @@ -202,7 +202,9 @@ func TestReplaceProfileSectionOverridesDecisions(t *testing.T) { // refuses is a validation failure at the override set. {&handlers.APIError{Status: http.StatusBadRequest, Code: "unknown_recipe", Message: "section_type not registered: nope"}, TypeValidationFailed, locationOverrides}, {&handlers.APIError{Status: http.StatusBadRequest, Code: "invalid_config", Message: "library_ids: at least one"}, TypeValidationFailed, locationOverrides}, - // An admin-only recipe on a server that does not allow custom sections. + // A new Editor's picks row from a profile that is not an admin. + // v2 names the rule, where v1 keeps the message from when a server + // setting decided this. {&handlers.APIError{Status: http.StatusForbidden, Code: "custom_disabled", Message: "this server does not allow profiles to build custom sections"}, TypePermissionDenied, ""}, {&handlers.APIError{Status: http.StatusInternalServerError, Code: TypeInternalError.ID, Message: "Failed to save overrides"}, TypeInternalError, ""}, } { @@ -214,6 +216,9 @@ func TestReplaceProfileSectionOverridesDecisions(t *testing.T) { if tc.want == TypeInternalError && strings.Contains(p.Detail, "Failed") { t.Errorf("internal detail leaked: %q", p.Detail) } + if tc.want == TypePermissionDenied && p.Detail != "Only an admin can add an Editor's picks row." { + t.Errorf("custom_disabled detail = %q", p.Detail) + } } } @@ -226,6 +231,9 @@ func TestResetProfileSectionOverrides(t *testing.T) { } } +// TestGetProfileSectionSettings: title is what the profile sees and +// default_title the administrator's own title for that row, so a renamed row +// carries both; a profile-built row has no administrator title. func TestGetProfileSectionSettings(t *testing.T) { svc := &fakeProfileSections{} h := newTestHandler(t, sectionDeps(svc)) @@ -234,8 +242,8 @@ func TestGetProfileSectionSettings(t *testing.T) { t.Fatal(rec.Body.String()) } want := `{"items":[` + - `{"id":"s-continue","section_type":"continue_watching","title":"Continue Watching","featured":false,"item_limit":20,"hidden":true,"is_custom":false,"customized":true,"position":0},` + - `{"id":"u-gems","section_type":"hidden_gems","title":"Hidden gems","featured":false,"item_limit":12,"hidden":false,"is_custom":true,"customized":false,"position":1,"config":{"library_ids":[3]}}` + + `{"id":"s-continue","section_type":"continue_watching","title":"Keep watching","default_title":"Continue Watching","featured":false,"item_limit":20,"hidden":true,"is_custom":false,"customized":true,"position":0},` + + `{"id":"u-gems","section_type":"hidden_gems","title":"Hidden gems","default_title":"","featured":false,"item_limit":12,"hidden":false,"is_custom":true,"customized":false,"position":1,"config":{"library_ids":[3]}}` + `]}` + "\n" if rec.Body.String() != want { t.Fatalf("body = %s", rec.Body.String()) @@ -275,8 +283,11 @@ func TestProfileSectionsDenied(t *testing.T) { } // A missing service fails closed. unwired := sectionDeps(nil) - unwired.ProfileSections, unwired.SectionFlags = nil, nil + unwired.ProfileSections = nil hu := newTestHandler(t, unwired) requireProblem(t, do(t, hu, http.MethodGet, "/api/v2/profile/sections", "", auth), TypeDependencyUnavailable) - requireProblem(t, do(t, hu, http.MethodGet, "/api/v2/profile/sections/flags", "", auth), TypeDependencyUnavailable) + // The flag is a constant now and needs no service. + if rec := do(t, hu, http.MethodGet, "/api/v2/profile/sections/flags", "", auth); rec.Code != 200 { + t.Fatalf("unwired flags: %d %s", rec.Code, rec.Body.String()) + } } diff --git a/internal/apiv2/recipe_catalog_fixture_test.go b/internal/apiv2/recipe_catalog_fixture_test.go new file mode 100644 index 0000000000..46f0164784 --- /dev/null +++ b/internal/apiv2/recipe_catalog_fixture_test.go @@ -0,0 +1,65 @@ +package apiv2 + +import ( + "bytes" + "encoding/json" + "flag" + "net/http" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/Silo-Server/silo-server/internal/api/handlers" +) + +var updateRecipeCatalogFixture = flag.Bool("update-recipe-catalog-fixture", false, + "rewrite the web recipe catalog fixture from the real recipe registry") + +// recipeCatalogFixture is the GET /api/v2/sections/recipes body the web tests +// read. It lives under web/ rather than testdata/ because the web payload, +// label and gate tests are what depend on it; keeping one copy for both trees +// means a recipe change cannot pass one side and drift on the other. +var recipeCatalogFixture = filepath.Join("..", "..", "web", "src", "lib", "homeRows", "__fixtures__", "recipeCatalog.v2.json") + +// TestRecipeCatalogMatchesWebFixture fails when a recipe's preset key, type, +// category, default params or admin_only flag changes without the web +// fixture changing with it. The catalog takes no viewer, so one fixture +// covers every account. Run with -update-recipe-catalog-fixture to rewrite +// the fixture, and say in the pull request why each changed preset changed. +func TestRecipeCatalogMatchesWebFixture(t *testing.T) { + deps, _ := homeDeps(t) + deps.Recipes = &handlers.RecipeHandler{} + rec := do(t, newTestHandler(t, deps), http.MethodGet, "/api/v2/sections/recipes", "", viewerHeaders()) + if rec.Code != http.StatusOK { + t.Fatalf("GET /api/v2/sections/recipes = %d: %s", rec.Code, rec.Body.String()) + } + var got bytes.Buffer + if err := json.Indent(&got, bytes.TrimSpace(rec.Body.Bytes()), "", " "); err != nil { + t.Fatalf("indent catalog: %v", err) + } + got.WriteByte('\n') + + if *updateRecipeCatalogFixture { + if err := os.WriteFile(recipeCatalogFixture, got.Bytes(), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + return + } + want, err := os.ReadFile(recipeCatalogFixture) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + // Compare values, not bytes, so reformatting the fixture cannot fail + // the test without a catalog change. + var gotValue, wantValue any + if err := json.Unmarshal(got.Bytes(), &gotValue); err != nil { + t.Fatalf("decode catalog: %v", err) + } + if err := json.Unmarshal(want, &wantValue); err != nil { + t.Fatalf("decode fixture %s: %v", recipeCatalogFixture, err) + } + if !reflect.DeepEqual(gotValue, wantValue) { + t.Fatalf("the recipe catalog no longer matches %s; rerun with -update-recipe-catalog-fixture and review the diff", recipeCatalogFixture) + } +} diff --git a/internal/apiv2/router.go b/internal/apiv2/router.go index 77a02ba74f..2f255d6785 100644 --- a/internal/apiv2/router.go +++ b/internal/apiv2/router.go @@ -117,7 +117,6 @@ type Dependencies struct { WatchTogetherPicker WatchTogetherPickerService WatchTogetherCapability WatchTogetherCapabilityService WatchTogetherSuggestions WatchTogetherSuggestionService - AdminSectionSettingsWrite AdminSectionSettingsWriteService AdminDashboardStats AdminDashboardStatsService AdminHardwareAcceleration AdminHardwareAccelerationService AdminDashboardLayout AdminDashboardLayoutService @@ -395,9 +394,6 @@ type Dependencies struct { // AdminProfileSections reads and writes any account's profile page // layouts for an administrator (*handlers.SectionHandler). AdminProfileSections AdminProfileSectionService - // SectionFlags reads the profile-facing sections settings - // (*handlers.SectionSettingsHandler). - SectionFlags SectionFlagService // Requests serves media requests and the discovery surface // (*requests.Service, the value *handlers.RequestsHandler wraps). AdminSubtitleInspection AdminSubtitleInspectionService @@ -430,6 +426,9 @@ type Dependencies struct { // CollectionImports creates synced collections from external lists and // searches MDBList (*handlers.UserCollectionImportHandler). CollectionImports CollectionImportService + // ScheduleZone reports the time zone cron collection schedules run in on + // this node; nil reads the process's local zone. Fixtures pin it. + ScheduleZone func() CollectionScheduleTimeZone // CatalogAccess resolves a viewer's access filter (*handlers.ItemsHandler); // every catalog read needs it alongside its own seam. CatalogAccess CatalogAccessService @@ -838,12 +837,6 @@ type ProfileSectionService interface { ResolveProfileSectionSettings(ctx context.Context, userID int, profileID, scope string, libraryID *int, filter mediacatalog.AccessFilter) ([]sections.ResolvedSection, error) } -// SectionFlagService is the slice of *handlers.SectionSettingsHandler -// getProfileSectionFlags uses. -type SectionFlagService interface { - AllowProfileCustomSections(ctx context.Context) bool -} - // LibraryService is the slice of *catalog.FolderRepository updateProfile // uses to validate a library allowlist before the store sees it. type LibraryService interface { @@ -1088,7 +1081,7 @@ type CatalogAccessService interface { type CatalogBrowseService interface { Browse(ctx context.Context, v handlers.ItemViewer, req mediacatalog.CatalogRequest, groupedByWork bool) (handlers.CatalogBrowseView, error) Filters(ctx context.Context, v handlers.ItemViewer, req mediacatalog.CatalogRequest, includeTechnical bool) (handlers.CatalogFiltersView, error) - SearchFacet(ctx context.Context, v handlers.ItemViewer, req mediacatalog.CatalogRequest, facet, prefix string, limit int) (handlers.CatalogFacetSearchView, error) + SearchFacet(ctx context.Context, v handlers.ItemViewer, req mediacatalog.CatalogRequest, facet, q string, limit int) (handlers.CatalogFacetSearchView, error) AudiobookGroups(ctx context.Context, v handlers.ItemViewer, query mediacatalog.AudiobookGroupsQuery) (handlers.AudiobookGroupsView, error) } diff --git a/internal/apiv2/schema_names.go b/internal/apiv2/schema_names.go index be6353ef79..8823b55295 100644 --- a/internal/apiv2/schema_names.go +++ b/internal/apiv2/schema_names.go @@ -13,7 +13,6 @@ import ( // Domain-owned documents have explicit product names in the native contract. // These names are API identifiers, independent of their Go package spelling. var domainSchemaNames = map[reflect.Type]string{ - reflect.TypeFor[templates.Bundle](): "CollectionTemplateBundle", reflect.TypeFor[templates.Catalog](): "CollectionTemplateCatalog", reflect.TypeFor[templates.Template](): "CollectionTemplate", reflect.TypeFor[onboarding.Step](): "OnboardingStep", diff --git a/internal/catalog/added_at_library_sort_db_test.go b/internal/catalog/added_at_library_sort_db_test.go new file mode 100644 index 0000000000..a5615264bc --- /dev/null +++ b/internal/catalog/added_at_library_sort_db_test.go @@ -0,0 +1,100 @@ +package catalog + +import ( + "context" + "fmt" + "slices" + "testing" + "time" +) + +// An added_at sort scoped to libraries orders by each item's earliest arrival +// in those libraries, then title, then content ID, and cursor pages walk that +// order without gaps or repeats, whether one library or several are in scope. +func TestAddedAtLibrarySortPagesByArrivalDB(t *testing.T) { + pool := newBatchEquivTestPool(t) + ctx := context.Background() + prefix := fmt.Sprintf("added-sort-%d", time.Now().UnixNano()) + + var libA, libB int + for _, lib := range []*int{&libA, &libB} { + if err := pool.QueryRow(ctx, `INSERT INTO media_folders (type, name, enabled) VALUES ('movies', $1, true) RETURNING id`, + fmt.Sprintf("%s-%p", prefix, lib)).Scan(lib); err != nil { + t.Fatalf("seed folder: %v", err) + } + } + base := time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC) + type seed struct { + name string + seenA int // days before base; -1 when not in library A + seenB int + wantOne int // position in the library-A order; -1 when absent + } + seeds := []seed{ + {"e", 0, -1, 0}, + {"b", 2, -1, 1}, // ties with "d" on arrival; title orders them + {"d", 2, -1, 2}, + {"a", 5, 1, 3}, // also in B, more recently + {"c", 9, -1, 4}, + {"f", -1, 3, -1}, + } + var ids []string + for _, s := range seeds { + id := prefix + "-" + s.name + ids = append(ids, id) + batchEquivExec(t, pool, `INSERT INTO media_items (content_id, type, title) VALUES ($1, 'movie', $2)`, id, s.name) + if s.seenA >= 0 { + batchEquivExec(t, pool, `INSERT INTO media_item_libraries (content_id, media_folder_id, first_seen_at) VALUES ($1, $2, $3)`, id, libA, base.AddDate(0, 0, -s.seenA)) + } + if s.seenB >= 0 { + batchEquivExec(t, pool, `INSERT INTO media_item_libraries (content_id, media_folder_id, first_seen_at) VALUES ($1, $2, $3)`, id, libB, base.AddDate(0, 0, -s.seenB)) + } + } + t.Cleanup(func() { + batchEquivExec(t, pool, `DELETE FROM media_items WHERE content_id = ANY($1)`, ids) + batchEquivExec(t, pool, `DELETE FROM media_folders WHERE id = ANY($1)`, []int{libA, libB}) + }) + + executor := &QueryExecutor{Pool: pool} + walk := func(libraries []int) []string { + t.Helper() + def := ApplySmartCollectionItemLimit(QueryDefinition{ + MediaScope: "movie", + LibraryIDs: libraries, + Sort: QuerySort{Field: "added_at", Order: "desc"}, + }.Normalize()) + var got []string + var after *QueryCursor + for range len(seeds) + 1 { + page, err := executor.PreviewCursorPage(ctx, def, AccessFilter{}, 2, after, false) + if err != nil { + t.Fatal(err) + } + for _, item := range page.Items { + got = append(got, item.ContentID) + } + if !page.HasMore { + return got + } + after = page.Next + } + t.Fatalf("cursor did not finish: %v", got) + return nil + } + + wantOne := make([]string, 5) + for i, s := range seeds { + if s.wantOne >= 0 { + wantOne[s.wantOne] = ids[i] + } + } + if got := walk([]int{libA}); !slices.Equal(got, wantOne) { + t.Fatalf("one library: got %v, want %v", got, wantOne) + } + // Across both libraries "a" keeps its earlier arrival in A, so "f" (B + // only) sorts ahead of it. + wantBoth := []string{prefix + "-e", prefix + "-b", prefix + "-d", prefix + "-f", prefix + "-a", prefix + "-c"} + if got := walk([]int{libA, libB}); !slices.Equal(got, wantBoth) { + t.Fatalf("two libraries: got %v, want %v", got, wantBoth) + } +} diff --git a/internal/catalog/browse.go b/internal/catalog/browse.go index 266e67e378..620d0bc088 100644 --- a/internal/catalog/browse.go +++ b/internal/catalog/browse.go @@ -92,11 +92,19 @@ type BrowseResult struct { // BrowseRepository provides browse/filter queries on the media_items table. type BrowseRepository struct { pool *pgxpool.Pool + // facetValues caches facet value lists for typeahead; every resolver + // over this repository shares it. + facetValues *facetValueCache } // NewBrowseRepository creates a new BrowseRepository. func NewBrowseRepository(pool *pgxpool.Pool) *BrowseRepository { - return &BrowseRepository{pool: pool} + return &BrowseRepository{pool: pool, facetValues: newFacetValueCache(facetValueCacheTTL, facetValueCacheBudget)} +} + +// facetFetcher returns the production facetFetcher over this repository. +func (r *BrowseRepository) facetFetcher() *pgxFacetFetcher { + return &pgxFacetFetcher{pool: r.pool, values: r.facetValues} } // Pool returns the underlying pgx pool for ad-hoc queries. @@ -1106,91 +1114,10 @@ func listDistinctAudiobookSeriesWithSource( return queryDistinctStrings(ctx, pool, query, args) } -// searchDistinctArrayColumnWithSource prefix-searches the distinct values -// of an array column (genres, studios, networks, countries) within the -// scoped result set. Returns up to limit matches in alphabetical order -// plus a hasMore flag (true when the underlying result set held more -// rows than limit). -func searchDistinctArrayColumnWithSource( - ctx context.Context, - pool *pgxpool.Pool, - column string, - filters BrowseFilters, - baseRelation string, - mediaScope string, - prefix string, - limit int, -) ([]string, bool, error) { - prefix = strings.TrimSpace(prefix) - if limit <= 0 { - return []string{}, false, nil - } - fromClause, whereClause, args, empty := filterWhereClauseForSource(filters, baseRelation, mediaScope) - if empty { - return []string{}, false, nil - } - args = append(args, prefix+"%") - prefixIdx := len(args) - // LOWER() in ORDER BY: this query is already wrapped in a subquery - // (the DISTINCT UNNEST), so the outer ORDER BY can reference any - // expression freely. - query := fmt.Sprintf(` - SELECT name FROM ( - SELECT DISTINCT UNNEST(mi.%s) AS name - FROM %s - %s - ) vals - WHERE name IS NOT NULL - AND BTRIM(name) <> '' - AND LOWER(name) LIKE LOWER($%d) - ORDER BY LOWER(name) ASC - LIMIT %d - `, column, fromClause, whereClause, prefixIdx, limit+1) - return queryFacetSearchResults(ctx, pool, query, args, limit) -} - -// searchDistinctScalarColumnWithSource is the scalar (e.g. content_rating) -// variant of searchDistinctArrayColumnWithSource. -func searchDistinctScalarColumnWithSource( - ctx context.Context, - pool *pgxpool.Pool, - column string, - filters BrowseFilters, - baseRelation string, - mediaScope string, - prefix string, - limit int, -) ([]string, bool, error) { - prefix = strings.TrimSpace(prefix) - if limit <= 0 { - return []string{}, false, nil - } - fromClause, whereClause, args, empty := filterWhereClauseForSource(filters, baseRelation, mediaScope) - if empty { - return []string{}, false, nil - } - args = append(args, prefix+"%") - prefixIdx := len(args) - // DISTINCT in an inline subquery so the outer ORDER BY can apply - // LOWER() without violating the SELECT DISTINCT rule. - query := fmt.Sprintf(` - SELECT name FROM ( - SELECT DISTINCT mi.%s AS name - FROM %s - %s - AND mi.%s IS NOT NULL - AND BTRIM(mi.%s) <> '' - AND LOWER(mi.%s) LIKE LOWER($%d) - ) matches - ORDER BY LOWER(name) ASC - LIMIT %d - `, column, fromClause, browseFilterPrefix(whereClause), column, column, column, prefixIdx, limit+1) - return queryFacetSearchResults(ctx, pool, query, args, limit) -} - // searchDistinctPeopleByKindWithSource is the typeahead equivalent of -// listDistinctPeopleByKindWithSource; powers /api/v1/catalog/filters/search -// for facet=author and facet=narrator. +// listDistinctPeopleByKindWithSource, for facet=author and facet=narrator: +// names whose lowercased form matches the LIKE pattern, ordered by +// LOWER(name), each with its title count. The pattern is lowercased in SQL. func searchDistinctPeopleByKindWithSource( ctx context.Context, pool *pgxpool.Pool, @@ -1198,76 +1125,74 @@ func searchDistinctPeopleByKindWithSource( filters BrowseFilters, baseRelation string, mediaScope string, - prefix string, + pattern string, limit int, -) ([]string, bool, error) { - prefix = strings.TrimSpace(prefix) +) ([]FacetValue, bool, error) { if limit <= 0 { - return []string{}, false, nil + return []FacetValue{}, false, nil } fromClause, whereClause, args, empty := filterWhereClauseForSource(filters, baseRelation, mediaScope) if empty { - return []string{}, false, nil + return []FacetValue{}, false, nil } args = append(args, int(kind)) kindIdx := len(args) - args = append(args, prefix+"%") - prefixIdx := len(args) - // DISTINCT in an inline subquery so the outer ORDER BY can apply - // LOWER() without violating Postgres' "ORDER BY expressions must - // appear in select list" rule for SELECT DISTINCT. + args = append(args, pattern) + patternIdx := len(args) query := fmt.Sprintf(` - SELECT name FROM ( - SELECT DISTINCT p.name AS name + SELECT name, n FROM ( + SELECT p.name AS name, count(DISTINCT mi.content_id) AS n FROM %s JOIN item_people ip ON ip.content_id = mi.content_id AND ip.kind = $%d JOIN people p ON p.id = ip.person_id %s AND p.name IS NOT NULL AND BTRIM(p.name) <> '' - AND LOWER(p.name) LIKE LOWER($%d) + AND LOWER(p.name) LIKE LOWER($%d) ESCAPE '\' + GROUP BY p.name ) matches - ORDER BY LOWER(name) ASC + ORDER BY LOWER(name), name LIMIT %d - `, fromClause, kindIdx, browseFilterPrefix(whereClause), prefixIdx, limit+1) - return queryFacetSearchResults(ctx, pool, query, args, limit) + `, fromClause, kindIdx, browseFilterPrefix(whereClause), patternIdx, limit+1) + return searchFacetValues(ctx, pool, query, args, limit) } // searchDistinctAudiobookSeriesWithSource is the typeahead equivalent of -// listDistinctAudiobookSeriesWithSource. +// listDistinctAudiobookSeriesWithSource, matching and ordering like +// searchDistinctPeopleByKindWithSource. func searchDistinctAudiobookSeriesWithSource( ctx context.Context, pool *pgxpool.Pool, filters BrowseFilters, baseRelation string, mediaScope string, - prefix string, + pattern string, limit int, -) ([]string, bool, error) { - prefix = strings.TrimSpace(prefix) +) ([]FacetValue, bool, error) { if limit <= 0 { - return []string{}, false, nil + return []FacetValue{}, false, nil } fromClause, whereClause, args, empty := filterWhereClauseForSource(filters, baseRelation, mediaScope) if empty { - return []string{}, false, nil + return []FacetValue{}, false, nil } - args = append(args, prefix+"%") - prefixIdx := len(args) + args = append(args, pattern) + patternIdx := len(args) query := fmt.Sprintf(` - SELECT name FROM ( - SELECT DISTINCT BTRIM(s.series_name) AS name + SELECT name, n FROM ( + SELECT BTRIM(s.series_name) AS name, count(DISTINCT mi.content_id) AS n FROM %s JOIN %s s ON s.content_id = mi.content_id %s AND s.series_name IS NOT NULL AND BTRIM(s.series_name) <> '' - AND LOWER(BTRIM(s.series_name)) LIKE LOWER($%d) + AND LOWER(BTRIM(s.series_name)) LIKE LOWER($%d) ESCAPE '\' + GROUP BY 1 ) names - ORDER BY LOWER(name) ASC + ORDER BY LOWER(name), name LIMIT %d - `, fromClause, bookSeriesTableForMediaScope(mediaScope), browseFilterPrefix(whereClause), prefixIdx, limit+1) - return queryFacetSearchResults(ctx, pool, query, args, limit) + `, fromClause, bookSeriesTableForMediaScope(mediaScope), browseFilterPrefix(whereClause), patternIdx, limit+1) + return searchFacetValues(ctx, pool, query, args, limit) } func bookSeriesTableForMediaScope(mediaScope string) string { @@ -1277,39 +1202,6 @@ func bookSeriesTableForMediaScope(mediaScope string) string { return "audiobook_series" } -// queryFacetSearchResults executes a facet search query that was built -// with LIMIT N+1 and returns the first N rows plus a hasMore flag -// indicating whether an additional row was present (i.e. the result set -// exceeded the requested limit). -func queryFacetSearchResults(ctx context.Context, pool *pgxpool.Pool, query string, args []any, limit int) ([]string, bool, error) { - rows, err := pool.Query(ctx, query, args...) - if err != nil { - return nil, false, err - } - defer rows.Close() - values := make([]string, 0, limit) - hasMore := false - for rows.Next() { - var value string - if err := rows.Scan(&value); err != nil { - return nil, false, err - } - value = strings.TrimSpace(value) - if value == "" { - continue - } - if len(values) >= limit { - hasMore = true - continue - } - values = append(values, value) - } - if err := rows.Err(); err != nil { - return nil, false, err - } - return values, hasMore, nil -} - func listDistinctJSONBLanguageWithSource( ctx context.Context, pool *pgxpool.Pool, diff --git a/internal/catalog/catalog_collection_cursor.go b/internal/catalog/catalog_collection_cursor.go index 724f7a9615..e7bd388a86 100644 --- a/internal/catalog/catalog_collection_cursor.go +++ b/internal/catalog/catalog_collection_cursor.go @@ -91,16 +91,15 @@ func (r *CatalogResolver) resolveLibraryCollectionCursor(ctx context.Context, re return nil, ErrCatalogSourceNotFound } result, err := r.resolveCollectionWithEffectiveSort(ctx, req, access, userstore.CollectionKindLibrary, collection.ID, collection.SortConfig, func(effective CatalogRequest) (*CatalogResult, error) { - if IsLiveQueryType(collection.CollectionType) || catalogCollectionUsesLiveQuery(collection.QueryDefinition) { - def, err := parseCatalogCollectionQueryDefinition(collection.QueryDefinition) - if err != nil { - return nil, err - } - def, ok := scopeLibraryCollectionDefinition(def, collection) - if !ok { - return &CatalogResult{Items: []*models.MediaItem{}, Total: 0, HasMore: false, TotalExact: true}, nil - } - return r.resolveSmartCollectionCursor(ctx, effective, access, def) + membership, err := catalogLibraryCollectionMembership(collection, access) + if err != nil { + return nil, err + } + if membership.OutOfScope { + return &CatalogResult{Items: []*models.MediaItem{}, TotalExact: true}, nil + } + if membership.Live { + return r.resolveSmartCollectionCursor(ctx, effective, access, membership.Query) } return r.resolveLibraryMembershipQueryCursor(ctx, effective, access) }) @@ -124,6 +123,10 @@ func (r *CatalogResolver) resolveUserCollectionCursor(ctx context.Context, req C if err != nil || !ProfileCanAccessCollection(collection, access.ProfileID) { return nil, ErrCatalogSourceNotFound } + access, err = r.userCollectionAccess(ctx, access, collection) + if err != nil { + return nil, err + } result, err := r.resolveCollectionWithEffectiveSort(ctx, req, access, userstore.CollectionKindUser, collection.ID, []byte(collection.SortConfig), func(effective CatalogRequest) (*CatalogResult, error) { sqlState := userstore.HasCatalogSQLState(store) if !sqlState { diff --git a/internal/catalog/catalog_collection_library_scope_test.go b/internal/catalog/catalog_collection_library_scope_test.go index 276788fb29..bdd0b3f806 100644 --- a/internal/catalog/catalog_collection_library_scope_test.go +++ b/internal/catalog/catalog_collection_library_scope_test.go @@ -14,7 +14,7 @@ import ( "github.com/Silo-Server/silo-server/internal/models" ) -func TestScopeLibraryCollectionDefinition(t *testing.T) { +func TestResolveLibraryCollectionMembershipNarrowsToCollectionLibraries(t *testing.T) { cases := []struct { name string saved []int @@ -31,12 +31,24 @@ func TestScopeLibraryCollectionDefinition(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - got, ok := scopeLibraryCollectionDefinition(QueryDefinition{LibraryIDs: tc.saved}, &tc.collection) - if ok != tc.wantOK { - t.Fatalf("ok = %v, want %v", ok, tc.wantOK) + definition, err := json.Marshal(QueryDefinition{LibraryIDs: tc.saved}) + if err != nil { + t.Fatal(err) + } + tc.collection.CollectionType = "smart" + tc.collection.QueryDefinition = definition + got, err := ResolveLibraryCollectionMembership(&tc.collection, nil) + if err != nil { + t.Fatal(err) + } + if !got.Live { + t.Fatal("smart collection resolved as stored") + } + if ok := !got.OutOfScope; ok != tc.wantOK { + t.Fatalf("in scope = %v, want %v", ok, tc.wantOK) } - if ok && !slices.Equal(got.LibraryIDs, tc.want) { - t.Fatalf("LibraryIDs = %v, want %v", got.LibraryIDs, tc.want) + if tc.wantOK && !slices.Equal(got.Query.LibraryIDs, tc.want) { + t.Fatalf("LibraryIDs = %v, want %v", got.Query.LibraryIDs, tc.want) } }) } @@ -82,7 +94,7 @@ func TestLibraryCollectionWithoutLibraryOverlapResolvesEmptyDB(t *testing.T) { t.Fatal(err) } repo := NewLibraryCollectionRepository(pool) - c, err := repo.Create(ctx, CreateLibraryCollectionInput{LibraryID: libraries[1], Slug: prefix, Title: "Scope", QueryDefinition: definition}) + c, err := repo.Create(ctx, CreateLibraryCollectionInput{LibraryID: libraries[1], Slug: prefix, Title: "Scope", CollectionType: "smart", QueryDefinition: definition}) if err != nil { t.Fatal(err) } diff --git a/internal/catalog/catalog_resolver.go b/internal/catalog/catalog_resolver.go index a6e81434af..d904bdaf2c 100644 --- a/internal/catalog/catalog_resolver.go +++ b/internal/catalog/catalog_resolver.go @@ -87,23 +87,26 @@ type facetFetcher interface { // AudiobookSeries returns distinct series_name values from the active // book series table joined onto the scoped result set. AudiobookSeries(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) - // SearchDistinctArrayColumn prefix-searches an array-column facet - // (genres, studios, networks, countries). Returns up to limit - // alphabetical matches and hasMore=true when more would be available. - SearchDistinctArrayColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) - // SearchDistinctScalarColumn is the scalar-column variant (e.g. - // content_rating, original_language). - SearchDistinctScalarColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) - // SearchPeopleByKind is the typeahead equivalent of PeopleByKind. - SearchPeopleByKind(ctx context.Context, kind models.PersonKind, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) - // SearchAudiobookSeries is the typeahead equivalent of AudiobookSeries. - SearchAudiobookSeries(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) + // SearchColumnValues answers a typeahead over an array-column facet + // (genres, studios, networks, countries) or a scalar one + // (content_rating, original_language). See facetSearchMode for how + // values match and order; hasMore is true when more values matched + // than the search limit. + SearchColumnValues(ctx context.Context, column facetColumn, filters BrowseFilters, baseRelation string, mediaScope string, search facetSearch) ([]FacetValue, bool, error) + // SearchPeopleByKind is the typeahead equivalent of PeopleByKind: + // names whose lowercased form matches the LIKE pattern, A-Z. + SearchPeopleByKind(ctx context.Context, kind models.PersonKind, filters BrowseFilters, baseRelation string, mediaScope string, pattern string, limit int) ([]FacetValue, bool, error) + // SearchAudiobookSeries is the typeahead equivalent of AudiobookSeries, + // matching like SearchPeopleByKind. + SearchAudiobookSeries(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string, pattern string, limit int) ([]FacetValue, bool, error) } // pgxFacetFetcher is the production facetFetcher. It dispatches each method to -// the existing package-level helpers using the supplied pool. +// the existing package-level helpers using the supplied pool. values caches +// column facet value lists for typeahead; nil disables caching. type pgxFacetFetcher struct { - pool *pgxpool.Pool + pool *pgxpool.Pool + values *facetValueCache } func (f *pgxFacetFetcher) DistinctArrayColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) { @@ -134,20 +137,16 @@ func (f *pgxFacetFetcher) AudiobookSeries(ctx context.Context, filters BrowseFil return listDistinctAudiobookSeriesWithSource(ctx, f.pool, filters, baseRelation, mediaScope) } -func (f *pgxFacetFetcher) SearchDistinctArrayColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) { - return searchDistinctArrayColumnWithSource(ctx, f.pool, column, filters, baseRelation, mediaScope, prefix, limit) +func (f *pgxFacetFetcher) SearchColumnValues(ctx context.Context, column facetColumn, filters BrowseFilters, baseRelation string, mediaScope string, search facetSearch) ([]FacetValue, bool, error) { + return searchColumnFacet(ctx, f.pool, f.values, column, filters, baseRelation, mediaScope, search) } -func (f *pgxFacetFetcher) SearchDistinctScalarColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) { - return searchDistinctScalarColumnWithSource(ctx, f.pool, column, filters, baseRelation, mediaScope, prefix, limit) +func (f *pgxFacetFetcher) SearchPeopleByKind(ctx context.Context, kind models.PersonKind, filters BrowseFilters, baseRelation string, mediaScope string, pattern string, limit int) ([]FacetValue, bool, error) { + return searchDistinctPeopleByKindWithSource(ctx, f.pool, kind, filters, baseRelation, mediaScope, pattern, limit) } -func (f *pgxFacetFetcher) SearchPeopleByKind(ctx context.Context, kind models.PersonKind, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) { - return searchDistinctPeopleByKindWithSource(ctx, f.pool, kind, filters, baseRelation, mediaScope, prefix, limit) -} - -func (f *pgxFacetFetcher) SearchAudiobookSeries(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) { - return searchDistinctAudiobookSeriesWithSource(ctx, f.pool, filters, baseRelation, mediaScope, prefix, limit) +func (f *pgxFacetFetcher) SearchAudiobookSeries(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string, pattern string, limit int) ([]FacetValue, bool, error) { + return searchDistinctAudiobookSeriesWithSource(ctx, f.pool, filters, baseRelation, mediaScope, pattern, limit) } // previewExecutor is the seam consumed by previewQuerySource so tests can @@ -169,6 +168,9 @@ type CatalogResolver struct { // to observe how many times the executor is asked for a result page. previewExecutorForScope func(scope string, snapshot *time.Time) previewExecutor watchlistPromoter WatchlistPromoter + // collectionOwners limits another profile's shared personal collection + // to its owner's access. Without it such a collection cannot be read. + collectionOwners PersonalCollectionAccess } // WatchlistPromoter moves a profile's watchlist entries for titles the @@ -195,7 +197,7 @@ func NewCatalogResolver(browseRepo *BrowseRepository, itemRepo *ItemRepository) searchProvider: NewPostgresSearchProvider(itemRepo), } if browseRepo != nil { - r.facets = &pgxFacetFetcher{pool: browseRepo.pool} + r.facets = browseRepo.facetFetcher() } return r } @@ -208,6 +210,23 @@ func (r *CatalogResolver) WithUserStoreProvider(provider userstore.UserStoreProv return r } +// WithPersonalCollectionAccess installs the owner access user_collection +// reads apply to another profile's shared collection. +func (r *CatalogResolver) WithPersonalCollectionAccess(owners PersonalCollectionAccess) *CatalogResolver { + if r == nil { + return nil + } + r.collectionOwners = owners + return r +} + +// userCollectionAccess is the filter a user_collection read of collection +// uses: the viewer's, limited to the owner's access when another profile +// owns it. +func (r *CatalogResolver) userCollectionAccess(ctx context.Context, viewer AccessFilter, collection *userstore.Collection) (AccessFilter, error) { + return PersonalCollectionFilter(ctx, r.collectionOwners, viewer, viewer.UserID, viewer.ProfileID, collection.CreatorProfileID) +} + func (r *CatalogResolver) WithEpisodeRepository(repo *EpisodeRepository) *CatalogResolver { if r == nil { return nil @@ -695,12 +714,12 @@ func (r *CatalogResolver) resolveLibraryCollectionItems( collection *models.LibraryCollection, collectionRepo *LibraryCollectionRepository, ) (*CatalogResult, error) { - if IsLiveQueryType(collection.CollectionType) { - return r.resolveLiveLibraryCollectionSource(ctx, req, access, collection) + membership, err := catalogLibraryCollectionMembership(collection, access) + if err != nil { + return nil, err } - - if catalogCollectionUsesLiveQuery(collection.QueryDefinition) { - return r.resolveLiveLibraryCollectionSource(ctx, req, access, collection) + if membership.Live { + return r.resolveLiveLibraryCollectionSource(ctx, req, access, membership) } collectionItems, err := collectionRepo.ListItems(ctx, collection.ID) @@ -714,16 +733,11 @@ func (r *CatalogResolver) resolveLibraryCollectionItems( return r.resolveExactOrderedItems(ctx, contentIDs, req, access) } -func (r *CatalogResolver) resolveLiveLibraryCollectionSource(ctx context.Context, req CatalogRequest, access AccessFilter, collection *models.LibraryCollection) (*CatalogResult, error) { - def, err := parseCatalogCollectionQueryDefinition(collection.QueryDefinition) - if err != nil { - return nil, fmt.Errorf("%w: parsing library collection query_definition: %v", ErrInvalidCatalogRequest, err) +func (r *CatalogResolver) resolveLiveLibraryCollectionSource(ctx context.Context, req CatalogRequest, access AccessFilter, membership LibraryCollectionMembership) (*CatalogResult, error) { + if membership.OutOfScope { + return &CatalogResult{Items: []*models.MediaItem{}, TotalExact: true}, nil } - def, ok := scopeLibraryCollectionDefinition(def, collection) - if !ok { - return &CatalogResult{Items: []*models.MediaItem{}, Total: 0, HasMore: false, TotalExact: true}, nil - } - def = ApplySmartCollectionItemLimit(def) + def := membership.Query if catalogRequestHasOverlay(req) { items, err := r.resolveCollectionQueryBaseItems(ctx, def, stripCatalogUserScope(access)) if err != nil { @@ -754,6 +768,10 @@ func (r *CatalogResolver) resolveUserCollectionSource(ctx context.Context, req C if err != nil || !ProfileCanAccessCollection(collection, access.ProfileID) { return nil, ErrCatalogSourceNotFound } + access, err = r.userCollectionAccess(ctx, access, collection) + if err != nil { + return nil, err + } return r.resolveCollectionWithEffectiveSort( ctx, req, access, userstore.CollectionKindUser, collection.ID, []byte(collection.SortConfig), @@ -1228,12 +1246,22 @@ func (r *CatalogResolver) ListFiltersWithOptions(ctx context.Context, req Catalo return r.listFiltersForSource(ctx, filters, options, "media_items mi", req.Query.MediaScope) } -// CatalogFacetSearchResult is the typed return for SearchFacet. Matches -// is in alphabetical order; HasMore is true when the underlying result -// set held more rows than the supplied limit. +// CatalogFacetSearchResult is the typed return for SearchFacet. type CatalogFacetSearchResult struct { + // Matches are the values that start with q, case-insensitively, A-Z; + // none for an empty q. HasMore is true when more matched than the + // limit. Matches []string HasMore bool + // Values are the ranked answer, each with its title count in scope. + // For column facets (genre, studio, network, country, + // original_language, content_rating) a value matches when it or any + // word in it starts with q; whole-value matches rank first, then more + // titles, then A-Z, and an empty q returns the most common values. + // People and series facets answer the same names as Matches. + // ValuesHasMore is true when more values matched than the limit. + Values []FacetValue + ValuesHasMore bool } // catalogFacetSearchMaxLimit caps how many matches a single typeahead @@ -1241,27 +1269,113 @@ type CatalogFacetSearchResult struct { // the page; clients that need more pages should narrow the prefix. const catalogFacetSearchMaxLimit = 50 -// SearchFacet powers /api/v1/catalog/filters/search. The request scope -// (libraries / access / media scope) mirrors ListFiltersWithOptions; -// the additional facet + prefix + limit arguments come from the URL. -// Empty or whitespace-only prefix returns no matches — the caller -// should fall back to the bulk /catalog/filters endpoint for the -// initial dropdown render. -func (r *CatalogResolver) SearchFacet(ctx context.Context, req CatalogRequest, access AccessFilter, facet string, prefix string, limit int) (*CatalogFacetSearchResult, error) { +// clampFacetSearchLimit applies the typeahead's default and cap. +func clampFacetSearchLimit(limit int) int { + if limit <= 0 { + return 20 + } + return min(limit, catalogFacetSearchMaxLimit) +} + +// SearchFacet powers the v2 catalog facet typeahead. The request scope +// (libraries / access / media scope) mirrors ListFiltersWithOptions; the +// additional facet + q + limit arguments come from the URL. Column facets +// are searched in the scope's cached value list, so they can lag catalog +// changes by facetValueCacheTTL. +func (r *CatalogResolver) SearchFacet(ctx context.Context, req CatalogRequest, access AccessFilter, facet string, q string, limit int) (*CatalogFacetSearchResult, error) { if r == nil || r.browseRepo == nil { return nil, fmt.Errorf("catalog resolver requires a browse repository") } + q = strings.TrimSpace(q) + limit = clampFacetSearchLimit(limit) + result := &CatalogFacetSearchResult{Matches: []string{}, Values: []FacetValue{}} + column, isColumn := facetColumns[facet] + if q == "" && !isColumn { + return result, nil + } + if req.Source == CatalogSourceHistory && req.SnapshotAt == nil { + // The snapshot is part of the facet value cache key, so a + // per-request "now" would never hit. The next whole minute still + // covers every event watched so far. + req.SnapshotAt = new(time.Now().UTC().Truncate(time.Minute).Add(time.Minute)) + } + scope, err := r.facetSearchScope(ctx, req, access) + if err != nil { + return nil, err + } + if scope.empty { + return result, nil + } + facets := r.facets + if facets == nil { + facets = r.browseRepo.facetFetcher() + } + + if !isColumn { + values, hasMore, err := searchNamedFacet(ctx, facets, facet, scope.filters, scope.baseRelation, scope.mediaScope, likePrefixPattern(q), limit) + if err != nil { + return nil, err + } + result.Values, result.ValuesHasMore = nonNilFacetValues(values), hasMore + result.Matches, result.HasMore = facetValueNames(values), hasMore + return result, nil + } + search := func(mode facetSearchMode) ([]FacetValue, bool, error) { + values, hasMore, err := facets.SearchColumnValues(ctx, column, scope.filters, scope.baseRelation, scope.mediaScope, facetSearch{Q: q, Limit: limit, Mode: mode}) + return nonNilFacetValues(values), hasMore, err + } + if q != "" { + matches, hasMore, err := search(facetSearchPrefix) + if err != nil { + return nil, err + } + result.Matches, result.HasMore = facetValueNames(matches), hasMore + } + if result.Values, result.ValuesHasMore, err = search(facetSearchRanked); err != nil { + return nil, err + } + return result, nil +} + +// SearchFacetV1 answers /api/v1/catalog/filters/search as that frozen +// contract always has: see searchFacetV1. A blank prefix matches nothing. +func (r *CatalogResolver) SearchFacetV1(ctx context.Context, req CatalogRequest, access AccessFilter, facet string, prefix string, limit int) ([]string, bool, error) { + if r == nil || r.browseRepo == nil { + return nil, false, fmt.Errorf("catalog resolver requires a browse repository") + } prefix = strings.TrimSpace(prefix) if prefix == "" { - return &CatalogFacetSearchResult{Matches: []string{}}, nil + return []string{}, false, nil } - if limit <= 0 { - limit = 20 + scope, err := r.facetSearchScope(ctx, req, access) + if err != nil { + return nil, false, err + } + if scope.empty { + return []string{}, false, nil + } + matches, hasMore, err := searchFacetV1(ctx, r.browseRepo.pool, facet, scope.filters, scope.baseRelation, scope.mediaScope, prefix, clampFacetSearchLimit(limit)) + if err != nil { + return nil, false, err } - if limit > catalogFacetSearchMaxLimit { - limit = catalogFacetSearchMaxLimit + if matches == nil { + matches = []string{} } + return matches, hasMore, nil +} + +// facetScope is where a facet typeahead searches. +type facetScope struct { + filters BrowseFilters + baseRelation string + mediaScope string + // empty is true when the scope can hold no titles. + empty bool +} +// facetSearchScope validates a typeahead's catalog request and resolves +// the titles it searches. +func (r *CatalogResolver) facetSearchScope(ctx context.Context, req CatalogRequest, access AccessFilter) (facetScope, error) { var ( filters BrowseFilters earlyEmpty bool @@ -1270,109 +1384,123 @@ func (r *CatalogResolver) SearchFacet(ctx context.Context, req CatalogRequest, a switch req.Source { case CatalogSourceQuery: if err := validateCatalogQueryRequest(req, strings.TrimSpace(access.ProfileID) != ""); err != nil { - return nil, err + return facetScope{}, err } filters, earlyEmpty, err = catalogBrowseFilters(req, access) if err != nil { - return nil, err + return facetScope{}, err } case CatalogSourceFavorites, CatalogSourceWatchlist, CatalogSourceHistory: if err := validateCatalogPersonalRequest(req, strings.TrimSpace(access.ProfileID) != ""); err != nil { - return nil, err + return facetScope{}, err } if access.UserID <= 0 || strings.TrimSpace(access.ProfileID) == "" { - return nil, fmt.Errorf("%w: source %q requires active user scope", ErrInvalidCatalogRequest, "personal") + return facetScope{}, fmt.Errorf("%w: source %q requires active user scope", ErrInvalidCatalogRequest, "personal") } filters, earlyEmpty, err = catalogBrowseFilters(req, access) if err != nil { - return nil, err + return facetScope{}, err } if req.Source == CatalogSourceHistory { scopeHistoryFacetFilters(&filters, req, access) } else { store, err := r.catalogStoreForAccess(ctx, access) if err != nil { - return nil, err + return facetScope{}, err } filters.ContentIDs, err = r.loadPersonalSourceIDs(ctx, store, req, access) if err != nil { - return nil, err + return facetScope{}, err } } case CatalogSourcePerson: if err := validateCatalogPersonRequest(req); err != nil { - return nil, err + return facetScope{}, err } filters, earlyEmpty, err = catalogBrowseFilters(req, access) if err != nil { - return nil, err + return facetScope{}, err } filters.PersonID = req.PersonID case CatalogSourceLibraryCollection, CatalogSourceUserCollection: if err := validateCatalogCollectionRequest(req, strings.TrimSpace(access.ProfileID) != ""); err != nil { - return nil, err + return facetScope{}, err } contentIDs, err := r.loadCollectionSourceIDs(ctx, req, access) if err != nil { - return nil, err + return facetScope{}, err } filters, earlyEmpty, err = catalogBrowseFilters(req, access) if err != nil { - return nil, err + return facetScope{}, err } filters.ContentIDs = contentIDs default: - return nil, fmt.Errorf("%w: source %q is not supported", ErrInvalidCatalogRequest, req.Source) - } - if earlyEmpty { - return &CatalogFacetSearchResult{Matches: []string{}}, nil - } - - baseRelation := "media_items mi" - mediaScope := req.Query.MediaScope - if isEpisodeCatalogScope(req.Query.MediaScope) { - baseRelation = episodeCatalogBaseRelation + return facetScope{}, fmt.Errorf("%w: source %q is not supported", ErrInvalidCatalogRequest, req.Source) } + return facetScope{filters: filters, baseRelation: catalogBaseRelationForScope(req.Query.MediaScope), mediaScope: req.Query.MediaScope, empty: earlyEmpty}, nil +} - facets := r.facets - if facets == nil { - facets = &pgxFacetFetcher{pool: r.browseRepo.pool} +// facetValueNames returns the values without their counts, never nil. +func facetValueNames(values []FacetValue) []string { + out := make([]string, len(values)) + for i, v := range values { + out[i] = v.Value } + return out +} - matches, hasMore, err := dispatchFacetSearch(ctx, facets, facet, filters, baseRelation, mediaScope, prefix, limit) - if err != nil { - return nil, err +func nonNilFacetValues(values []FacetValue) []FacetValue { + if values == nil { + return []FacetValue{} } - return &CatalogFacetSearchResult{Matches: matches, HasMore: hasMore}, nil + return values } -// dispatchFacetSearch routes a facet name to the right facetFetcher -// method. The set of supported facet names mirrors what -// /api/v1/catalog/filters returns; anything else is rejected as an -// invalid request so callers learn about typos at the API boundary. -func dispatchFacetSearch(ctx context.Context, facets facetFetcher, facet string, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) { +// The column facet names and the media_items columns they read. +const ( + facetGenre = "genre" + facetStudio = "studio" + facetNetwork = "network" + facetCountry = "country" + facetOriginalLanguage = "original_language" + facetContentRating = "content_rating" + + columnGenres = "genres" + columnStudios = "studios" + columnNetworks = "networks" + columnCountries = "countries" + columnOriginalLanguage = "original_language" + columnContentRating = "content_rating" +) + +// facetColumns maps the column facet names to the media_items column each +// reads. +var facetColumns = map[string]facetColumn{ + facetGenre: {name: columnGenres, array: true}, + facetStudio: {name: columnStudios, array: true}, + facetNetwork: {name: columnNetworks, array: true}, + facetCountry: {name: columnCountries, array: true}, + facetOriginalLanguage: {name: columnOriginalLanguage}, + facetContentRating: {name: columnContentRating}, +} + +// searchNamedFacet routes the people and series facet names to their +// facetFetcher method. With facetColumns, the supported names mirror what +// /catalog/filters returns; anything else is rejected as an invalid +// request so callers learn about typos at the API boundary. pattern is a +// LIKE pattern matched against the lowercased name. +func searchNamedFacet(ctx context.Context, facets facetFetcher, facet string, filters BrowseFilters, baseRelation string, mediaScope string, pattern string, limit int) ([]FacetValue, bool, error) { switch facet { - case "genre": - return facets.SearchDistinctArrayColumn(ctx, "genres", filters, baseRelation, mediaScope, prefix, limit) - case "studio": - return facets.SearchDistinctArrayColumn(ctx, "studios", filters, baseRelation, mediaScope, prefix, limit) - case "network": - return facets.SearchDistinctArrayColumn(ctx, "networks", filters, baseRelation, mediaScope, prefix, limit) - case "country": - return facets.SearchDistinctArrayColumn(ctx, "countries", filters, baseRelation, mediaScope, prefix, limit) - case "original_language": - return facets.SearchDistinctScalarColumn(ctx, "original_language", filters, baseRelation, mediaScope, prefix, limit) - case "content_rating": - return facets.SearchDistinctScalarColumn(ctx, "content_rating", filters, baseRelation, mediaScope, prefix, limit) case "author": - return facets.SearchPeopleByKind(ctx, models.PersonKindAuthor, filters, baseRelation, mediaScope, prefix, limit) + return facets.SearchPeopleByKind(ctx, models.PersonKindAuthor, filters, baseRelation, mediaScope, pattern, limit) case "narrator": if mediaScope == "ebook" { return nil, false, fmt.Errorf("%w: narrator facet is not available for ebook scope", ErrInvalidCatalogRequest) } - return facets.SearchPeopleByKind(ctx, models.PersonKindNarrator, filters, baseRelation, mediaScope, prefix, limit) + return facets.SearchPeopleByKind(ctx, models.PersonKindNarrator, filters, baseRelation, mediaScope, pattern, limit) case "series": - return facets.SearchAudiobookSeries(ctx, filters, baseRelation, mediaScope, prefix, limit) + return facets.SearchAudiobookSeries(ctx, filters, baseRelation, mediaScope, pattern, limit) default: return nil, false, fmt.Errorf("%w: unknown facet %q", ErrInvalidCatalogRequest, facet) } @@ -1403,7 +1531,7 @@ func (r *CatalogResolver) listFiltersForSource( ) (*CatalogFiltersResult, error) { facets := r.facets if facets == nil { - facets = &pgxFacetFetcher{pool: r.browseRepo.pool} + facets = r.browseRepo.facetFetcher() } var ( @@ -1988,28 +2116,14 @@ func bytesTrimSpace(raw []byte) []byte { return []byte(strings.TrimSpace(string(raw))) } -func catalogCollectionUsesLiveQuery(raw json.RawMessage) bool { - trimmed := strings.TrimSpace(string(raw)) - return trimmed != "" && trimmed != "{}" && trimmed != "null" -} - -// scopeLibraryCollectionDefinition narrows a smart library collection's saved -// library_ids to the collection's own libraries. It reports false when the two -// don't overlap: the collection then matches nothing, never every library. -func scopeLibraryCollectionDefinition(def QueryDefinition, collection *models.LibraryCollection) (QueryDefinition, bool) { - required := collection.LibraryIDs - if len(required) == 0 && collection.LibraryID > 0 { - required = []int{collection.LibraryID} - } - if len(required) == 0 { - return def, true - } - if len(def.LibraryIDs) == 0 { - def.LibraryIDs = append([]int(nil), required...) - return def, true +// catalogLibraryCollectionMembership resolves how the catalog lists a library +// collection's members for the viewer, within the libraries access allows. +func catalogLibraryCollectionMembership(collection *models.LibraryCollection, access AccessFilter) (LibraryCollectionMembership, error) { + membership, err := ResolveLibraryCollectionMembership(collection, access.AllowedLibraryIDs) + if err != nil { + return LibraryCollectionMembership{}, fmt.Errorf("%w: library collection query_definition: %w", ErrInvalidCatalogRequest, err) } - def.LibraryIDs = intersectInts(def.LibraryIDs, required) - return def, len(def.LibraryIDs) > 0 + return membership, nil } func stripCatalogUserScope(access AccessFilter) AccessFilter { @@ -2033,18 +2147,10 @@ func (r *CatalogResolver) catalogStoreForAccess(ctx context.Context, access Acce } // ProfileCanAccessCollection reports whether profileID may view a personal -// collection. Creator access is represented in AllowedProfileIDs at write time, -// so this is the canonical check for both browse and preference endpoints. +// collection (userstore.Collection.VisibleTo). It is the canonical check for +// both browse and preference endpoints. func ProfileCanAccessCollection(collection *userstore.Collection, profileID string) bool { - if collection == nil || strings.TrimSpace(profileID) == "" { - return false - } - for _, allowed := range collection.AllowedProfileIDs { - if allowed == profileID { - return true - } - } - return false + return collection.VisibleTo(strings.TrimSpace(profileID)) } // PersonalListEntry pairs a list member with the timestamp it was added to @@ -2169,16 +2275,15 @@ func (r *CatalogResolver) loadCollectionSource(ctx context.Context, req CatalogR if err != nil || collection.Visibility != LibraryCollectionVisibilityVisible { return "", nil, ErrCatalogSourceNotFound } - if IsLiveQueryType(collection.CollectionType) || catalogCollectionUsesLiveQuery(collection.QueryDefinition) { - def, err := parseCatalogCollectionQueryDefinition(collection.QueryDefinition) - if err != nil { - return "", nil, fmt.Errorf("%w: parsing library collection query_definition: %w", ErrInvalidCatalogRequest, err) - } - def, ok := scopeLibraryCollectionDefinition(def, collection) - if !ok { - return collection.Title, nil, nil - } - items, err := r.resolveCollectionQueryBaseItems(ctx, ApplySmartCollectionItemLimit(def), stripCatalogUserScope(access)) + membership, err := catalogLibraryCollectionMembership(collection, access) + if err != nil { + return "", nil, err + } + if membership.OutOfScope { + return collection.Title, []*models.MediaItem{}, nil + } + if membership.Live { + items, err := r.resolveCollectionQueryBaseItems(ctx, membership.Query, stripCatalogUserScope(access)) return collection.Title, items, err } @@ -2201,6 +2306,10 @@ func (r *CatalogResolver) loadCollectionSource(ctx context.Context, req CatalogR if err != nil || !ProfileCanAccessCollection(collection, access.ProfileID) { return "", nil, ErrCatalogSourceNotFound } + access, err = r.userCollectionAccess(ctx, access, collection) + if err != nil { + return "", nil, err + } var items []*models.MediaItem if IsLiveQueryType(collection.CollectionType) { def, err := parseCatalogCollectionQueryDefinition([]byte(collection.QueryDefinition)) diff --git a/internal/catalog/catalog_resolver_test.go b/internal/catalog/catalog_resolver_test.go index 3013300e5b..1050fb9ab8 100644 --- a/internal/catalog/catalog_resolver_test.go +++ b/internal/catalog/catalog_resolver_test.go @@ -285,22 +285,17 @@ func (s *stubFacetFetcher) AudiobookSeries(ctx context.Context, filters BrowseFi return nil, nil } -func (s *stubFacetFetcher) SearchDistinctArrayColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) { +func (s *stubFacetFetcher) SearchColumnValues(ctx context.Context, column facetColumn, filters BrowseFilters, baseRelation string, mediaScope string, search facetSearch) ([]FacetValue, bool, error) { s.hit() return nil, false, nil } -func (s *stubFacetFetcher) SearchDistinctScalarColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) { +func (s *stubFacetFetcher) SearchPeopleByKind(ctx context.Context, kind models.PersonKind, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]FacetValue, bool, error) { s.hit() return nil, false, nil } -func (s *stubFacetFetcher) SearchPeopleByKind(ctx context.Context, kind models.PersonKind, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) { - s.hit() - return nil, false, nil -} - -func (s *stubFacetFetcher) SearchAudiobookSeries(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) { +func (s *stubFacetFetcher) SearchAudiobookSeries(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]FacetValue, bool, error) { s.hit() return nil, false, nil } @@ -346,23 +341,19 @@ func (s *recordingFacetFetcher) AudiobookSeries(ctx context.Context, filters Bro return nil, nil } -func (s *recordingFacetFetcher) SearchDistinctArrayColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) { +func (s *recordingFacetFetcher) SearchColumnValues(ctx context.Context, column facetColumn, filters BrowseFilters, baseRelation string, mediaScope string, search facetSearch) ([]FacetValue, bool, error) { return nil, false, nil } -func (s *recordingFacetFetcher) SearchDistinctScalarColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) { - return nil, false, nil -} - -func (s *recordingFacetFetcher) SearchPeopleByKind(ctx context.Context, kind models.PersonKind, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) { +func (s *recordingFacetFetcher) SearchPeopleByKind(ctx context.Context, kind models.PersonKind, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]FacetValue, bool, error) { s.mu.Lock() s.searchPeopleKind = kind s.searchMediaScope = mediaScope s.mu.Unlock() - return []string{"Author"}, false, nil + return []FacetValue{{Value: "Author", Count: 1}}, false, nil } -func (s *recordingFacetFetcher) SearchAudiobookSeries(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) { +func (s *recordingFacetFetcher) SearchAudiobookSeries(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]FacetValue, bool, error) { return nil, false, nil } @@ -603,7 +594,10 @@ func TestSearchFacet_EbookScopeForwardsMediaScope(t *testing.T) { if err != nil { t.Fatalf("SearchFacet returned error: %v", err) } - if len(result.Matches) != 1 || result.Matches[0] != "Author" { + if len(result.Values) != 1 || result.Values[0] != (FacetValue{Value: "Author", Count: 1}) { + t.Fatalf("values = %v, want Author", result.Values) + } + if !slices.Equal(result.Matches, []string{"Author"}) { t.Fatalf("matches = %v, want Author", result.Matches) } diff --git a/internal/catalog/collection_collages.go b/internal/catalog/collection_collages.go new file mode 100644 index 0000000000..9c701d4656 --- /dev/null +++ b/internal/catalog/collection_collages.go @@ -0,0 +1,340 @@ +package catalog + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "runtime/debug" + "sync" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/collage" +) + +// A collection without an uploaded, template or imported poster shows a +// collage of its titles' posters. Server collections +// (library_collection_posters.go) and personal collections +// (personal_collection_collages.go) share this machinery; only where their +// collages are stored and how their sources are chosen differ. The titles a +// collage shows depend on the viewer: each viewer sees the first titles it can +// access, so a restricted profile never sees the poster of a title it can't +// open (#1618). +// +// Each distinct set of source posters is one stored collage, shared by every +// viewer that selects the same posters. Its key is a hash of those posters, so +// a change to the titles, their order, or their artwork selects a new collage +// rather than serving a stale one. Reads never compose: a read serves the +// stored collage and builds a missing one in the background. +const ( + // CollectionCollageSourceLimit is the most member posters one collage shows. + CollectionCollageSourceLimit = 4 + + // collectionCollageLayout versions how a collage is composed from its + // sources. Changing the composition must change this value, so collages + // already stored are rebuilt instead of reused. + collectionCollageLayout = "collage-v1" + + // A collage in use is touched at most this often, so reads rarely write. + collectionCollageTouchInterval = 24 * time.Hour + // A collage nobody has read for this long is retired the next time a + // collage of the same collection is built. + collectionCollageUnusedAfter = 7 * 24 * time.Hour + + collageBuildConcurrency = 2 + collageBuildMaxPending = 256 + collageBuildTimeout = 2 * time.Minute + // After a failed build, reads don't retry the same collage for this long. + collageBuildRetryAfter = 10 * time.Minute +) + +// ErrCollectionCollageBeingCollected reports that the artwork revision +// collector is deleting a collage's objects, so it can't be rebuilt yet. +var ErrCollectionCollageBeingCollected = errors.New("collection collage is being collected") + +// CollectionPoster is the poster one viewer sees for a collection. +type CollectionPoster struct { + Path string + Thumbhash string + // CollageKey names the viewer's collage. It is empty for an uploaded or + // template poster, which every viewer shares. + CollageKey string +} + +// CollectionCollageRef names one stored collage of a collection. +type CollectionCollageRef struct { + CollectionID string + Key string +} + +// CollectionCollage is one stored collage. +type CollectionCollage struct { + CollectionCollageRef + Path string + Thumbhash string + LastUsedAt time.Time +} + +// CollectionCollageKey returns the key of the collage composed from sources, +// the member poster paths in the order the collage shows them. +func CollectionCollageKey(sources []string) string { + sum := sha256.New() + sum.Write([]byte(collectionCollageLayout)) + for _, source := range sources { + sum.Write([]byte{0}) + sum.Write([]byte(source)) + } + return hex.EncodeToString(sum.Sum(nil)[:8]) +} + +// collageStore keeps one kind of collection's stored collages. Deleting a +// stored row hands its objects to the artwork revision collector in the same +// transaction (a table trigger), so no store deletes objects itself. +type collageStore interface { + GetCollectionCollages(ctx context.Context, refs []CollectionCollageRef) (map[CollectionCollageRef]CollectionCollage, error) + TouchCollectionCollages(ctx context.Context, refs []CollectionCollageRef) error + // SaveCollectionCollage stores a built collage and releases its path's + // reservation, or fails when the collection is gone. + SaveCollectionCollage(ctx context.Context, c CollectionCollage) error + RetireUnusedCollectionCollages(ctx context.Context, collectionID string, cutoff time.Time) (int, error) + ReserveCollectionCollagePath(ctx context.Context, path string) (held bool, err error) +} + +// collageSet serves, builds and retires the collages kept in one store. +type collageSet struct { + store collageStore + gen CollageGenerator + queue *collageBuildQueue +} + +// serve returns the stored collage of each collection in ids whose sources +// are listed, keyed by collection ID. A collage not built yet is left out and +// built in the background, so a later read finds it. +func (c collageSet) serve(ctx context.Context, ids []string, sources map[string][]string) map[string]CollectionPoster { + posters := make(map[string]CollectionPoster, len(sources)) + refs := make([]CollectionCollageRef, 0, len(sources)) + for _, id := range ids { + if src := sources[id]; len(src) > 0 { + refs = append(refs, CollectionCollageRef{CollectionID: id, Key: CollectionCollageKey(src)}) + } + } + if len(refs) == 0 { + return posters + } + stored, err := c.store.GetCollectionCollages(ctx, refs) + if err != nil { + slog.WarnContext(ctx, "collage: failed to load collection collages", "component", "catalog", "error", err) + return posters + } + + var touch []CollectionCollageRef + now := time.Now() + for _, ref := range refs { + stored, ok := stored[ref] + if !ok || stored.Path == "" { + c.buildLater(ref, sources[ref.CollectionID]) + continue + } + posters[ref.CollectionID] = CollectionPoster{Path: stored.Path, Thumbhash: stored.Thumbhash, CollageKey: ref.Key} + if now.Sub(stored.LastUsedAt) > collectionCollageTouchInterval { + touch = append(touch, ref) + } + } + if err := c.store.TouchCollectionCollages(ctx, touch); err != nil { + slog.DebugContext(ctx, "collage: failed to touch collection collages", "component", "catalog", "error", err) + } + return posters +} + +// prepare builds the collage composed from sources unless it is already +// stored. It returns collage.ErrNotEnoughImages when sources is empty. +func (c collageSet) prepare(ctx context.Context, collectionID string, sources []string) error { + if len(sources) == 0 { + return collage.ErrNotEnoughImages + } + ref := CollectionCollageRef{CollectionID: collectionID, Key: CollectionCollageKey(sources)} + stored, err := c.store.GetCollectionCollages(ctx, []CollectionCollageRef{ref}) + if err != nil { + return err + } + if s, ok := stored[ref]; ok && s.Path != "" { + return nil + } + return c.build(ctx, ref, sources) +} + +// build composes and stores one collage, then retires the collection's +// collages that went unused. Nodes that build the same collage at once write +// the same objects, so the result doesn't depend on which one wins. The path +// stays reserved in the artwork revision collector until the row is saved, so +// a failed build leaves nothing behind. +func (c collageSet) build(ctx context.Context, ref CollectionCollageRef, sources []string) error { + held, err := c.store.ReserveCollectionCollagePath(ctx, c.gen.CollectionCollagePath(ref.CollectionID, ref.Key)) + if err != nil { + return err + } + if held { + return ErrCollectionCollageBeingCollected + } + path, thumbhash, err := c.gen.ComposeCollectionCollage(ctx, ref.CollectionID, ref.Key, sources) + if err != nil { + return err + } + if err := c.store.SaveCollectionCollage(ctx, CollectionCollage{CollectionCollageRef: ref, Path: path, Thumbhash: thumbhash}); err != nil { + return err + } + if _, err := c.store.RetireUnusedCollectionCollages(ctx, ref.CollectionID, time.Now().Add(-collectionCollageUnusedAfter)); err != nil { + slog.WarnContext(ctx, "collage: failed to retire unused collages", "component", "catalog", "collection_id", ref.CollectionID, "error", err) + } + return nil +} + +// buildLater builds a collage a read found missing, off the request. +func (c collageSet) buildLater(ref CollectionCollageRef, sources []string) { + c.queue.runLater(ref, func(ctx context.Context) error { return c.build(ctx, ref, sources) }) +} + +// collageBuildQueue runs the collage builds reads ask for in the background. +// It skips a collage already being built, bounds how many builds wait and +// run, and holds off retrying a collage whose build just failed. A build that +// dies with its node is retried by the next read on any node. +type collageBuildQueue struct { + mu sync.Mutex + // pending maps a collage to the time a read may ask for it again: zero + // while a build runs, the retry time after one failed. + pending map[CollectionCollageRef]time.Time + slots chan struct{} +} + +func newCollageBuildQueue() *collageBuildQueue { + return &collageBuildQueue{ + pending: make(map[CollectionCollageRef]time.Time), + slots: make(chan struct{}, collageBuildConcurrency), + } +} + +// claim reports whether a build of ref may start now and, if so, marks it +// running. +func (q *collageBuildQueue) claim(ref CollectionCollageRef, now time.Time) bool { + q.mu.Lock() + defer q.mu.Unlock() + if retryAt, ok := q.pending[ref]; ok && (retryAt.IsZero() || now.Before(retryAt)) { + return false + } + if len(q.pending) >= collageBuildMaxPending { + for pendingRef, retryAt := range q.pending { + if !retryAt.IsZero() && !now.Before(retryAt) { + delete(q.pending, pendingRef) + } + } + if len(q.pending) >= collageBuildMaxPending { + return false + } + } + q.pending[ref] = time.Time{} + return true +} + +// runLater runs work for ref off the request, unless work for ref is already +// running or recently failed. +func (q *collageBuildQueue) runLater(ref CollectionCollageRef, work func(ctx context.Context) error) { + if !q.claim(ref, time.Now()) { + return + } + go func() { + failed := true + defer func() { + if r := recover(); r != nil { + slog.Error("collage: build panic", "component", "catalog", "collection_id", ref.CollectionID, "panic", r, "stack", string(debug.Stack())) + } + q.finish(ref, failed, time.Now()) + }() + q.slots <- struct{}{} + defer func() { <-q.slots }() + + ctx, cancel := context.WithTimeout(context.Background(), collageBuildTimeout) + defer cancel() + err := work(ctx) + switch { + case err == nil: + failed = false + case errors.Is(err, collage.ErrNotEnoughImages): + slog.DebugContext(ctx, "collage: no usable source images", "component", "catalog", "collection_id", ref.CollectionID) + default: + slog.WarnContext(ctx, "collage: background build failed", "component", "catalog", "collection_id", ref.CollectionID, "error", err) + } + }() +} + +func (q *collageBuildQueue) finish(ref CollectionCollageRef, failed bool, now time.Time) { + q.mu.Lock() + defer q.mu.Unlock() + if failed { + q.pending[ref] = now.Add(collageBuildRetryAfter) + return + } + delete(q.pending, ref) +} + +// reserveCollectionCollagePath queues a collage path in the artwork revision +// collector before a collage is built under it, pushing any earlier entry past +// the build. If the build fails or its node dies before the collage is saved, +// the collector deletes whatever it uploaded; a saved row releases the +// reservation. It reports held when a collector worker is processing the +// path, since it may be deleting objects the build would upload; the build +// must then wait for a later read. +func reserveCollectionCollagePath(ctx context.Context, pool *pgxpool.Pool, path string) (held bool, err error) { + tx, err := pool.Begin(ctx) + if err != nil { + return false, fmt.Errorf("beginning collection collage reservation: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + err = tx.QueryRow(ctx, ` + SELECT locked_at IS NOT NULL + FROM artwork_revision_gc_candidates + WHERE original_path = $1 + FOR UPDATE + `, path).Scan(&held) + switch { + case errors.Is(err, pgx.ErrNoRows): + case err != nil: + return false, fmt.Errorf("checking collection collage reservation: %w", err) + case held: + return true, nil + } + if _, err := tx.Exec(ctx, `SELECT public.queue_collection_poster_objects($1)`, path); err != nil { + return false, fmt.Errorf("reserving collection collage path: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return false, fmt.Errorf("committing collection collage reservation: %w", err) + } + return false, nil +} + +// releaseCollectionCollageReservation drops the collector's queue entry for a +// collage path inside the transaction that saves its row: the row now +// protects the objects. +func releaseCollectionCollageReservation(ctx context.Context, tx pgx.Tx, path string) error { + if _, err := tx.Exec(ctx, ` + DELETE FROM artwork_revision_gc_candidates + WHERE original_path = $1 AND locked_at IS NULL + `, path); err != nil { + return fmt.Errorf("releasing collection collage reservation: %w", err) + } + return nil +} + +func splitCollectionCollageRefs(refs []CollectionCollageRef) ([]string, []string) { + ids := make([]string, len(refs)) + keys := make([]string, len(refs)) + for i, ref := range refs { + ids[i] = ref.CollectionID + keys[i] = ref.Key + } + return ids, keys +} diff --git a/internal/catalog/collection_sync_schedule.go b/internal/catalog/collection_sync_schedule.go index 69502f457d..3797c1194a 100644 --- a/internal/catalog/collection_sync_schedule.go +++ b/internal/catalog/collection_sync_schedule.go @@ -12,7 +12,8 @@ import ( var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) // ParseCronExpression validates a cron expression string. -// It accepts the standard 5-field format: minute hour dom month dow. +// It accepts the standard 5-field format: minute hour dom month dow, with an +// optional TZ= or CRON_TZ= prefix naming the zone the schedule runs in. func ParseCronExpression(expr string) error { _, err := cronParser.Parse(expr) if err != nil { @@ -29,25 +30,21 @@ func computeNextSyncAt(schedule *string) *time.Time { if schedule == nil || *schedule == "" { return nil } - sched, err := cronParser.Parse(*schedule) - if err != nil { - return nil - } - next := sched.Next(time.Now()) - jitter := time.Duration(rand.IntN(15*60)) * time.Second - next = next.Add(jitter) - return &next + return ComputeNextSyncAtFrom(*schedule, time.Now()) } // ComputeNextSyncAtFrom parses a cron expression and returns the next // scheduled time after the given reference time, with a small random jitter -// (0-15 minutes) to prevent thundering herd on subsequent cycles. +// (0-15 minutes) to prevent thundering herd on subsequent cycles. The +// expression is evaluated on the node's local clock (time.Local), whatever +// zone after is in, unless a TZ= or CRON_TZ= prefix names another zone. The +// result keeps after's location. func ComputeNextSyncAtFrom(schedule string, after time.Time) *time.Time { sched, err := cronParser.Parse(schedule) if err != nil { return nil } - next := sched.Next(after) + next := sched.Next(after.In(time.Local)).In(after.Location()) jitter := time.Duration(rand.IntN(15*60)) * time.Second next = next.Add(jitter) return &next diff --git a/internal/catalog/collection_sync_schedule_test.go b/internal/catalog/collection_sync_schedule_test.go new file mode 100644 index 0000000000..42c933db57 --- /dev/null +++ b/internal/catalog/collection_sync_schedule_test.go @@ -0,0 +1,70 @@ +package catalog + +import ( + "testing" + "time" +) + +// TestComputeNextSyncAtFromUsesLocalWallTime checks that a cron expression is +// evaluated on the node's local clock whatever zone the reference time is in, +// and that the result keeps the reference time's location. +func TestComputeNextSyncAtFromUsesLocalWallTime(t *testing.T) { + local := time.FixedZone("UTC-5", -5*60*60) + saved := time.Local + time.Local = local + t.Cleanup(func() { time.Local = saved }) + + after := time.Date(2026, time.July, 1, 12, 0, 0, 0, time.UTC) // 07:00 local + next := ComputeNextSyncAtFrom("30 4 * * *", after) + if next == nil { + t.Fatal("ComputeNextSyncAtFrom returned nil") + } + if next.Location() != time.UTC { + t.Errorf("location = %s, want UTC", next.Location()) + } + earliest := time.Date(2026, time.July, 2, 4, 30, 0, 0, local) + if next.Before(earliest) || !next.Before(earliest.Add(15*time.Minute)) { + t.Errorf("next = %s, want within 15 minutes after %s", next, earliest) + } +} + +// TestParseCronExpressionAcceptsZonePrefix checks that a schedule may still +// name its own zone with a TZ= or CRON_TZ= prefix, as /api/v1 always allowed. +func TestParseCronExpressionAcceptsZonePrefix(t *testing.T) { + for _, expr := range []string{"30 4 * * *", "CRON_TZ=UTC 30 4 * * *", "TZ=America/Chicago 30 4 * * *"} { + if err := ParseCronExpression(expr); err != nil { + t.Errorf("ParseCronExpression(%q) = %v", expr, err) + } + } +} + +// TestComputeNextSyncAtFromHonorsZonePrefix checks that a schedule with a TZ= +// or CRON_TZ= prefix runs in the zone the prefix names, not on the node's +// local clock. +func TestComputeNextSyncAtFromHonorsZonePrefix(t *testing.T) { + local := time.FixedZone("UTC-5", -5*60*60) + saved := time.Local + time.Local = local + t.Cleanup(func() { time.Local = saved }) + + tokyo, err := time.LoadLocation("Asia/Tokyo") + if err != nil { + t.Skipf("time zone database unavailable: %v", err) + } + after := time.Date(2026, time.July, 1, 12, 0, 0, 0, time.UTC) + for schedule, earliest := range map[string]time.Time{ + "CRON_TZ=UTC 30 4 * * *": time.Date(2026, time.July, 2, 4, 30, 0, 0, time.UTC), + "TZ=Asia/Tokyo 30 4 * * *": time.Date(2026, time.July, 2, 4, 30, 0, 0, tokyo), + } { + next := ComputeNextSyncAtFrom(schedule, after) + if next == nil { + t.Fatalf("ComputeNextSyncAtFrom(%q) returned nil", schedule) + } + if next.Location() != time.UTC { + t.Errorf("ComputeNextSyncAtFrom(%q) location = %s, want UTC", schedule, next.Location()) + } + if next.Before(earliest) || !next.Before(earliest.Add(15*time.Minute)) { + t.Errorf("ComputeNextSyncAtFrom(%q) = %s, want within 15 minutes after %s", schedule, next, earliest) + } + } +} diff --git a/internal/catalog/collection_type.go b/internal/catalog/collection_type.go index fdbcf31fc0..73eb0445d0 100644 --- a/internal/catalog/collection_type.go +++ b/internal/catalog/collection_type.go @@ -9,6 +9,7 @@ func IsLiveQueryType(collectionType string) bool { } // IsSyncableType returns true for collection types that support external sync. +// Mirrored by isListBackedCollectionType in web/src/lib/collections/types.ts. func IsSyncableType(collectionType string) bool { switch strings.TrimSpace(strings.ToLower(collectionType)) { case "mdblist", "tmdb", "trakt": diff --git a/internal/catalog/facet_search_v1.go b/internal/catalog/facet_search_v1.go new file mode 100644 index 0000000000..fd446dcafb --- /dev/null +++ b/internal/catalog/facet_search_v1.go @@ -0,0 +1,159 @@ +package catalog + +import ( + "context" + "fmt" + "strings" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// The /api/v1/catalog/filters/search answer is frozen: every facet returns +// names starting with the prefix, read live from the database and ordered +// by LOWER(name) under the database collation. The prefix is a LIKE +// pattern, so % and _ in it match any text. The queries below keep that +// answer; /api/v2 searches cached value lists instead (facet_values.go). + +// searchFacetV1 answers one v1 facet typeahead. facet is already known to +// be a supported name or is rejected by searchNamedFacet. +func searchFacetV1( + ctx context.Context, + pool *pgxpool.Pool, + facet string, + filters BrowseFilters, + baseRelation string, + mediaScope string, + prefix string, + limit int, +) ([]string, bool, error) { + if column, ok := facetColumns[facet]; ok { + if column.array { + return searchDistinctArrayColumnWithSource(ctx, pool, column.name, filters, baseRelation, mediaScope, prefix, limit) + } + return searchDistinctScalarColumnWithSource(ctx, pool, column.name, filters, baseRelation, mediaScope, prefix, limit) + } + values, hasMore, err := searchNamedFacet(ctx, &pgxFacetFetcher{pool: pool}, facet, filters, baseRelation, mediaScope, strings.TrimSpace(prefix)+"%", limit) + if err != nil { + return nil, false, err + } + names := make([]string, len(values)) + for i, v := range values { + names[i] = v.Value + } + return names, hasMore, nil +} + +// searchDistinctArrayColumnWithSource prefix-searches the distinct values +// of an array column (genres, studios, networks, countries) within the +// scoped result set. Returns up to limit matches in alphabetical order +// plus a hasMore flag (true when the underlying result set held more +// rows than limit). +func searchDistinctArrayColumnWithSource( + ctx context.Context, + pool *pgxpool.Pool, + column string, + filters BrowseFilters, + baseRelation string, + mediaScope string, + prefix string, + limit int, +) ([]string, bool, error) { + prefix = strings.TrimSpace(prefix) + if limit <= 0 { + return []string{}, false, nil + } + fromClause, whereClause, args, empty := filterWhereClauseForSource(filters, baseRelation, mediaScope) + if empty { + return []string{}, false, nil + } + args = append(args, prefix+"%") + prefixIdx := len(args) + // LOWER() in ORDER BY: this query is already wrapped in a subquery + // (the DISTINCT UNNEST), so the outer ORDER BY can reference any + // expression freely. + query := fmt.Sprintf(` + SELECT name FROM ( + SELECT DISTINCT UNNEST(mi.%s) AS name + FROM %s + %s + ) vals + WHERE name IS NOT NULL + AND BTRIM(name) <> '' + AND LOWER(name) LIKE LOWER($%d) + ORDER BY LOWER(name) ASC + LIMIT %d + `, column, fromClause, whereClause, prefixIdx, limit+1) + return queryFacetSearchResults(ctx, pool, query, args, limit) +} + +// searchDistinctScalarColumnWithSource is the scalar (e.g. content_rating) +// variant of searchDistinctArrayColumnWithSource. +func searchDistinctScalarColumnWithSource( + ctx context.Context, + pool *pgxpool.Pool, + column string, + filters BrowseFilters, + baseRelation string, + mediaScope string, + prefix string, + limit int, +) ([]string, bool, error) { + prefix = strings.TrimSpace(prefix) + if limit <= 0 { + return []string{}, false, nil + } + fromClause, whereClause, args, empty := filterWhereClauseForSource(filters, baseRelation, mediaScope) + if empty { + return []string{}, false, nil + } + args = append(args, prefix+"%") + prefixIdx := len(args) + // DISTINCT in an inline subquery so the outer ORDER BY can apply + // LOWER() without violating the SELECT DISTINCT rule. + query := fmt.Sprintf(` + SELECT name FROM ( + SELECT DISTINCT mi.%s AS name + FROM %s + %s + AND mi.%s IS NOT NULL + AND BTRIM(mi.%s) <> '' + AND LOWER(mi.%s) LIKE LOWER($%d) + ) matches + ORDER BY LOWER(name) ASC + LIMIT %d + `, column, fromClause, browseFilterPrefix(whereClause), column, column, column, prefixIdx, limit+1) + return queryFacetSearchResults(ctx, pool, query, args, limit) +} + +// queryFacetSearchResults executes a facet search query that was built +// with LIMIT N+1 and returns the first N rows plus a hasMore flag +// indicating whether an additional row was present (i.e. the result set +// exceeded the requested limit). +func queryFacetSearchResults(ctx context.Context, pool *pgxpool.Pool, query string, args []any, limit int) ([]string, bool, error) { + rows, err := pool.Query(ctx, query, args...) + if err != nil { + return nil, false, err + } + defer rows.Close() + values := make([]string, 0, limit) + hasMore := false + for rows.Next() { + var value string + if err := rows.Scan(&value); err != nil { + return nil, false, err + } + value = strings.TrimSpace(value) + if value == "" { + continue + } + if len(values) >= limit { + hasMore = true + continue + } + values = append(values, value) + } + if err := rows.Err(); err != nil { + return nil, false, err + } + return values, hasMore, nil +} diff --git a/internal/catalog/facet_search_v1_db_test.go b/internal/catalog/facet_search_v1_db_test.go new file mode 100644 index 0000000000..3728454e0f --- /dev/null +++ b/internal/catalog/facet_search_v1_db_test.go @@ -0,0 +1,127 @@ +package catalog + +import ( + "context" + "fmt" + "os" + "slices" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// The v1 facet typeahead is frozen: it must answer exactly what the query +// it shipped with answers, under the database's own collation, with LIKE +// wildcards in the prefix and names trimmed only after DISTINCT. +func TestSearchFacetV1AnswersTheFrozenQuery(t *testing.T) { + dsn := os.Getenv("SILO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("SILO_TEST_DATABASE_URL is not set") + } + ctx := t.Context() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("connect test database: %v", err) + } + t.Cleanup(pool.Close) + sfx := fmt.Sprintf("v1f%d", time.Now().UnixNano()) + var lib int + if err := pool.QueryRow(ctx, `INSERT INTO media_folders (type, name, enabled) VALUES ('movies', $1, true) RETURNING id`, sfx).Scan(&lib); err != nil { + t.Fatal(err) + } + var ids []string + t.Cleanup(func() { + ctx := context.Background() + _, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = ANY($1)`, ids) + _, _ = pool.Exec(ctx, `DELETE FROM media_folders WHERE id = $1`, lib) + }) + g := func(name string) string { return sfx + " " + name } + for i, genres := range [][]string{ + {g("Science Fiction"), g("Sci-Fi"), g("Comédie")}, + {g("Comedy"), g("Comic"), g("Drama") + " "}, + {g("Drama"), g("100% Fun"), g("100x Fun")}, + {g("Ab_c"), g("Abxc")}, + } { + id := fmt.Sprintf("%s-%d", sfx, i) + ids = append(ids, id) + if _, err := pool.Exec(ctx, `INSERT INTO media_items (content_id, type, title, status, genres) VALUES ($1, 'movie', $1, 'matched', $2)`, id, genres); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO media_item_libraries (content_id, media_folder_id) VALUES ($1, $2)`, id, lib); err != nil { + t.Fatal(err) + } + } + + // The query /api/v1/catalog/filters/search ran for array facets before + // the v2 typeahead got its cached value lists. + frozen := func(t *testing.T, prefix string, limit int) ([]string, bool) { + t.Helper() + rows, err := pool.Query(ctx, ` + SELECT name FROM ( + SELECT DISTINCT UNNEST(mi.genres) AS name + FROM media_items mi + JOIN media_item_libraries mil ON mil.content_id = mi.content_id AND mil.media_folder_id = $1 + ) vals + WHERE name IS NOT NULL + AND BTRIM(name) <> '' + AND LOWER(name) LIKE LOWER($2) + ORDER BY LOWER(name) ASC + LIMIT $3`, lib, strings.TrimSpace(prefix)+"%", limit+1) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var names []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + t.Fatal(err) + } + names = append(names, strings.TrimSpace(name)) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + if len(names) > limit { + return names[:limit], true + } + return names, false + } + + resolver := NewCatalogResolver(NewBrowseRepository(pool), nil) + req := CatalogRequest{Source: CatalogSourceQuery, Query: QueryDefinition{LibraryIDs: []int{lib}}} + for _, tc := range []struct { + prefix string + limit int + // want pins what holds under any collation; the frozen query + // decides the order. No two seeded names tie on LOWER(name), so + // that order is total. + want []string + }{ + {prefix: g("sci"), limit: 10, want: []string{g("Science Fiction"), g("Sci-Fi")}}, + {prefix: g("sci"), limit: 1}, + {prefix: g("com"), limit: 10, want: []string{g("Comédie"), g("Comedy"), g("Comic")}}, + {prefix: g("dra"), limit: 10, want: []string{g("Drama"), g("Drama")}}, + {prefix: g("100%"), limit: 10, want: []string{g("100% Fun"), g("100x Fun")}}, + {prefix: g("ab_c"), limit: 10, want: []string{g("Ab_c"), g("Abxc")}}, + {prefix: " " + g("c") + " ", limit: 2}, + } { + got, hasMore, err := resolver.SearchFacetV1(ctx, req, AccessFilter{}, "genre", tc.prefix, tc.limit) + if err != nil { + t.Fatal(err) + } + want, wantMore := frozen(t, tc.prefix, tc.limit) + if !slices.Equal(got, want) || hasMore != wantMore { + t.Errorf("q=%q limit=%d: got %q more=%v, the frozen query answers %q more=%v", tc.prefix, tc.limit, got, hasMore, want, wantMore) + } + if tc.want != nil && !slices.Equal(slices.Sorted(slices.Values(got)), slices.Sorted(slices.Values(tc.want))) { + t.Errorf("q=%q: got %q, want the names %q", tc.prefix, got, tc.want) + } + } + + if got, more, err := resolver.SearchFacetV1(ctx, req, AccessFilter{}, "genre", " ", 10); err != nil || got == nil || len(got) != 0 || more { + t.Errorf("blank prefix = %q more=%v err=%v, want an empty list", got, more, err) + } +} diff --git a/internal/catalog/facet_values.go b/internal/catalog/facet_values.go new file mode 100644 index 0000000000..419626270c --- /dev/null +++ b/internal/catalog/facet_values.go @@ -0,0 +1,516 @@ +package catalog + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "slices" + "strings" + "sync" + "time" + "unicode" + "unicode/utf8" + + "github.com/jackc/pgx/v5/pgxpool" + "golang.org/x/sync/singleflight" +) + +// FacetValue is one facet value and the number of titles in the scope that +// carry it. +type FacetValue struct { + Value string + Count int +} + +// facetSearchMode picks how a facet typeahead matches and orders values. +type facetSearchMode int + +const ( + // facetSearchRanked matches q at the start of the value or of any word in + // it. Whole-value prefix matches come first, then word-start matches; + // within each group more titles first, then A-Z. An empty q returns the + // most common values. It answers v2 values. + facetSearchRanked facetSearchMode = iota + // facetSearchPrefix matches q at the start of the value, A-Z by + // lowercased code points. It answers v2 matches. + facetSearchPrefix +) + +// facetSearch is one typeahead request against a facet. +type facetSearch struct { + Q string + Limit int + Mode facetSearchMode +} + +const ( + // facetValueCacheTTL bounds how stale a cached value list can be. A + // title added, removed or re-tagged shows up in typeahead within this + // window; each node keeps its own cache. + facetValueCacheTTL = 2 * time.Minute + // facetValueCacheBudget caps the values held across all cached lists + // (roughly 100 bytes each with the lowercased copy, so ~50 MB at the + // cap). Least recently used lists go first. A single scope with more + // distinct values than this is never cached; its searches push an + // escaped match into SQL instead. + facetValueCacheBudget = 500_000 + // facetValueLoadTimeout bounds one list build, including its wait for a + // fill slot; the build runs on after the request that started it is + // canceled. + facetValueLoadTimeout = 30 * time.Second + // facetValueFillLimit caps how many lists one node builds at once. A + // build can hold up to facetValueCacheBudget values before the budget + // check turns it away, so a burst of misses across many scopes, as + // after a restart, holds at most this many such lists in memory and + // runs at most this many list queries; the rest wait for a slot. + facetValueFillLimit = 4 +) + +// facetEntry is one cached value with its lowercased form for matching. +type facetEntry struct { + value string + lower string + count int +} + +// facetValueList is a scope's whole value list, sorted by count descending +// then case-insensitive A-Z. oversize marks a scope whose list exceeded the +// per-list cap; entries is then empty and searches go to SQL. +type facetValueList struct { + entries []facetEntry + oversize bool +} + +func newFacetValueList(values []FacetValue) *facetValueList { + entries := make([]facetEntry, 0, len(values)) + for _, v := range values { + entries = append(entries, facetEntry{value: v.Value, lower: strings.ToLower(v.Value), count: v.Count}) + } + slices.SortFunc(entries, func(a, b facetEntry) int { + if a.count != b.count { + return b.count - a.count + } + return compareFacetNames(a, b) + }) + return &facetValueList{entries: entries} +} + +// compareFacetNames orders entries case-insensitively A-Z, then by exact +// value so the order is total. +func compareFacetNames(a, b facetEntry) int { + if c := strings.Compare(a.lower, b.lower); c != 0 { + return c + } + return strings.Compare(a.value, b.value) +} + +// search answers one typeahead from the list. hasMore reports whether more +// values matched than limit. +func (l *facetValueList) search(s facetSearch) ([]FacetValue, bool) { + q := strings.ToLower(strings.TrimSpace(s.Q)) + limit := s.Limit + if s.Mode == facetSearchPrefix { + var matched []facetEntry + for _, e := range l.entries { + if strings.HasPrefix(e.lower, q) { + matched = append(matched, e) + } + } + slices.SortFunc(matched, compareFacetNames) + return facetValuesOf(matched, limit) + } + if q == "" { + return facetValuesOf(l.entries, limit) + } + // The list is already in count-then-name order, so each group keeps + // that order as it fills. Stop once the prefix group alone overflows. + var prefix, words []facetEntry + for _, e := range l.entries { + if strings.HasPrefix(e.lower, q) { + prefix = append(prefix, e) + if len(prefix) > limit { + break + } + continue + } + if len(words) <= limit && hasWordStartMatch(e.lower, q) { + words = append(words, e) + } + } + return facetValuesOf(append(prefix, words...), limit) +} + +func facetValuesOf(entries []facetEntry, limit int) ([]FacetValue, bool) { + hasMore := len(entries) > limit + if hasMore { + entries = entries[:limit] + } + out := make([]FacetValue, len(entries)) + for i, e := range entries { + out[i] = FacetValue{Value: e.value, Count: e.count} + } + return out, hasMore +} + +// hasWordStartMatch reports whether q starts a word of lower after its +// first one. A word starts after any rune that is not a letter or digit, +// so "Warner Bros./Pictures" has words "bros" and "pictures". +func hasWordStartMatch(lower, q string) bool { + if !strings.Contains(lower, q) { + return false + } + prevSep := false + for i, r := range lower { + sep := isFacetWordSeparator(r) + if i > 0 && prevSep && !sep && strings.HasPrefix(lower[i:], q) { + return true + } + prevSep = sep + } + return false +} + +func isFacetWordSeparator(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsDigit(r) +} + +// facetValueCache holds scope value lists in memory, keyed by a hash of +// the exact SQL and arguments that built them, so every access predicate +// (libraries, disabled libraries, maturity limits, personal sources) is +// part of the key and two viewers share a list only when their scope SQL +// is identical. Entries expire after ttl; the cache evicts least recently +// used lists once the values it holds exceed budget. Concurrent misses +// for one key run one load, and at most cap(fills) loads run at once. +type facetValueCache struct { + ttl time.Duration + budget int + loadTimeout time.Duration + now func() time.Time + flights singleflight.Group + // fills holds a slot for each list being built. + fills chan struct{} + + mu sync.Mutex + entries map[string]*facetCacheItem + // lru is the sentinel of a ring of the entries: lru.next is the most + // recently used, lru.prev the least. + lru facetCacheItem + used int +} + +type facetCacheItem struct { + key string + list *facetValueList + expires time.Time + weight int + prev, next *facetCacheItem +} + +func newFacetValueCache(ttl time.Duration, budget int) *facetValueCache { + c := &facetValueCache{ + ttl: ttl, + budget: budget, + loadTimeout: facetValueLoadTimeout, + now: time.Now, + fills: make(chan struct{}, facetValueFillLimit), + entries: make(map[string]*facetCacheItem), + } + c.lru.prev, c.lru.next = &c.lru, &c.lru + return c +} + +// maxListValues is the most values one list may hold and still be cached. +func (c *facetValueCache) maxListValues() int { + if c == nil { + return facetValueCacheBudget + } + return c.budget +} + +// get returns the cached list for key, or waits for the one load that +// builds it for every concurrent caller of the key. The load runs apart +// from the callers' contexts, bounded by loadTimeout: typeahead clients +// cancel a request on the next keystroke, and the next keystroke needs the +// same list. A caller whose context ends stops waiting; a failed load +// caches nothing and every waiter gets its error. +func (c *facetValueCache) get(ctx context.Context, key string, load func(context.Context) (*facetValueList, error)) (*facetValueList, error) { + if c == nil { + return load(ctx) + } + if l, ok := c.lookup(key); ok { + return l, nil + } + flight := c.flights.DoChan(key, func() (any, error) { + return c.fill(context.WithoutCancel(ctx), key, load) + }) + select { + case res := <-flight: + if res.Err != nil { + return nil, res.Err + } + l, ok := res.Val.(*facetValueList) + if !ok { + return nil, fmt.Errorf("facet value load returned %T", res.Val) + } + return l, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +// lookup returns key's list while it is fresh, dropping it once expired. +func (c *facetValueCache) lookup(key string) (*facetValueList, bool) { + c.mu.Lock() + defer c.mu.Unlock() + item, ok := c.entries[key] + if !ok { + return nil, false + } + if !c.now().Before(item.expires) { + c.removeLocked(item) + return nil, false + } + c.unlinkLocked(item) + c.pushFrontLocked(item) + return item.list, true +} + +// fill builds key's list in a fill slot and caches it when the load +// succeeds. A panicking load fails the flight instead of the process. +func (c *facetValueCache) fill(ctx context.Context, key string, load func(context.Context) (*facetValueList, error)) (l *facetValueList, err error) { + // A flight that starts just after another one stored the list finds it + // here instead of building it again. + if l, ok := c.lookup(key); ok { + return l, nil + } + ctx, cancel := context.WithTimeout(ctx, c.loadTimeout) + defer cancel() + select { + case c.fills <- struct{}{}: + case <-ctx.Done(): + return nil, ctx.Err() + } + defer func() { <-c.fills }() + defer func() { + if r := recover(); r != nil { + l, err = nil, fmt.Errorf("facet value load panicked: %v", r) + } + }() + if l, err = load(ctx); err != nil { + return nil, err + } + c.mu.Lock() + c.storeLocked(key, l) + c.mu.Unlock() + return l, nil +} + +func (c *facetValueCache) storeLocked(key string, l *facetValueList) { + weight := max(len(l.entries), 1) + if weight > c.budget { + return + } + item := &facetCacheItem{key: key, list: l, expires: c.now().Add(c.ttl), weight: weight} + c.pushFrontLocked(item) + c.entries[key] = item + c.used += weight + for c.used > c.budget { + c.removeLocked(c.lru.prev) + } +} + +func (c *facetValueCache) removeLocked(item *facetCacheItem) { + c.unlinkLocked(item) + delete(c.entries, item.key) + c.used -= item.weight +} + +func (c *facetValueCache) unlinkLocked(item *facetCacheItem) { + item.prev.next, item.next.prev = item.next, item.prev +} + +func (c *facetValueCache) pushFrontLocked(item *facetCacheItem) { + item.prev, item.next = &c.lru, c.lru.next + c.lru.next.prev = item + c.lru.next = item +} + +// facetColumn names a media_items column a facet reads, and whether it is a +// text[] (genres, studios, networks, countries) or a scalar text column. +type facetColumn struct { + name string + array bool +} + +// facetCacheKey hashes the list query and its arguments. Arguments that do +// not encode to JSON make the scope uncacheable rather than risk two scopes +// sharing a key. +func facetCacheKey(query string, args []any) (string, bool) { + encoded, err := json.Marshal(args) + if err != nil { + return "", false + } + sum := sha256.New() + sum.Write([]byte(query)) + sum.Write([]byte{0}) + sum.Write(encoded) + return string(sum.Sum(nil)), true +} + +// searchColumnFacet answers a typeahead over an array or scalar column +// facet from the scope's cached value list, building it on a miss. A scope +// too large to cache is searched in SQL. +func searchColumnFacet( + ctx context.Context, + pool *pgxpool.Pool, + cache *facetValueCache, + column facetColumn, + filters BrowseFilters, + baseRelation string, + mediaScope string, + s facetSearch, +) ([]FacetValue, bool, error) { + if s.Limit <= 0 { + return []FacetValue{}, false, nil + } + fromClause, whereClause, args, empty := filterWhereClauseForSource(filters, baseRelation, mediaScope) + if empty { + return []FacetValue{}, false, nil + } + values := facetValuesSource(column, fromClause, whereClause, filters.PersonID > 0) + maxValues := cache.maxListValues() + listQuery := fmt.Sprintf(`SELECT name, n FROM %s LIMIT %d`, values, maxValues+1) + load := func(ctx context.Context) (*facetValueList, error) { + got, err := scanFacetValues(ctx, pool, listQuery, args) + if err != nil { + return nil, fmt.Errorf("listing %s values: %w", column.name, err) + } + if len(got) > maxValues { + return &facetValueList{oversize: true}, nil + } + return newFacetValueList(got), nil + } + var l *facetValueList + var err error + if key, ok := facetCacheKey(listQuery, args); ok { + l, err = cache.get(ctx, key, load) + } else { + l, err = load(ctx) + } + if err != nil { + return nil, false, err + } + if !l.oversize { + matches, hasMore := l.search(s) + return matches, hasMore, nil + } + return searchFacetValuesSQL(ctx, pool, values, args, s) +} + +// facetValuesSource is a subquery of every non-blank (trimmed) value of the +// column in the scope with its title count, as (name, n). Credits joined +// for a person scope can repeat a title, so that scope counts distinct +// titles. +func facetValuesSource(column facetColumn, fromClause, whereClause string, distinctTitles bool) string { + count := "count(*)" + if distinctTitles { + count = "count(DISTINCT cid)" + } + value := "mi." + column.name + if column.array { + value = "UNNEST(" + value + ")" + } + return fmt.Sprintf(`( + SELECT name, %s AS n FROM ( + SELECT mi.content_id AS cid, BTRIM(%s) AS name + FROM %s + %s + ) vals + WHERE name <> '' + GROUP BY name + ) facet_values`, count, value, fromClause, whereClause) +} + +// searchFacetValuesSQL is the uncached search for a scope whose value list +// is over the cache cap. It matches and orders like facetValueList.search: +// q is escaped so % and _ are literal, names compare in code point order +// (COLLATE "C", as Go compares strings), and letters and digits decide +// word starts on both sides, though Postgres and Go may disagree on +// exotic characters. +func searchFacetValuesSQL(ctx context.Context, pool *pgxpool.Pool, values string, args []any, s facetSearch) ([]FacetValue, bool, error) { + q := strings.TrimSpace(s.Q) + args = slices.Clone(args) + bind := func(v any) int { + args = append(args, v) + return len(args) + } + var where, order string + switch { + case s.Mode == facetSearchPrefix: + where = fmt.Sprintf("WHERE LOWER(name) LIKE $%d ESCAPE '\\'", bind(likePrefixPattern(q))) + order = `LOWER(name) COLLATE "C", name COLLATE "C"` + case q == "": + order = `n DESC, LOWER(name) COLLATE "C", name COLLATE "C"` + default: + where = fmt.Sprintf("WHERE LOWER(name) ~ $%d", bind(`(^|[^[:alnum:]])`+escapeRegexLiteral(strings.ToLower(q)))) + order = fmt.Sprintf(`(LOWER(name) LIKE $%d ESCAPE '\') DESC, n DESC, LOWER(name) COLLATE "C", name COLLATE "C"`, bind(likePrefixPattern(q))) + } + // OFFSET 0 keeps Postgres from pushing the match below the GROUP BY, + // where it would run once per title instead of once per value. + query := fmt.Sprintf(`SELECT name, n FROM (SELECT name, n FROM %s OFFSET 0) v %s ORDER BY %s LIMIT %d`, values, where, order, s.Limit+1) + return searchFacetValues(ctx, pool, query, args, s.Limit) +} + +// escapeRegexLiteral escapes s for a Postgres regular expression. A +// backslash before a non-alphanumeric character always means that literal +// character, so escaping every ASCII punctuation or space is safe. +func escapeRegexLiteral(s string) string { + var b strings.Builder + for _, r := range s { + if r < utf8.RuneSelf && !unicode.IsLetter(r) && !unicode.IsDigit(r) { + b.WriteByte('\\') + } + b.WriteRune(r) + } + return b.String() +} + +// scanFacetValues reads (name, count) rows, trimming names and skipping +// blank ones. +func scanFacetValues(ctx context.Context, pool *pgxpool.Pool, query string, args []any) ([]FacetValue, error) { + rows, err := pool.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + values := []FacetValue{} + for rows.Next() { + var v FacetValue + if err := rows.Scan(&v.Value, &v.Count); err != nil { + return nil, err + } + v.Value = strings.TrimSpace(v.Value) + if v.Value == "" { + continue + } + values = append(values, v) + } + if err := rows.Err(); err != nil { + return nil, err + } + return values, nil +} + +// searchFacetValues runs a typeahead query built with LIMIT limit+1 and +// splits off hasMore. +func searchFacetValues(ctx context.Context, pool *pgxpool.Pool, query string, args []any, limit int) ([]FacetValue, bool, error) { + got, err := scanFacetValues(ctx, pool, query, args) + if err != nil { + return nil, false, err + } + if len(got) > limit { + return got[:limit], true, nil + } + return got, false, nil +} diff --git a/internal/catalog/facet_values_db_test.go b/internal/catalog/facet_values_db_test.go new file mode 100644 index 0000000000..296849e533 --- /dev/null +++ b/internal/catalog/facet_values_db_test.go @@ -0,0 +1,169 @@ +package catalog + +import ( + "context" + "fmt" + "os" + "slices" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// seedFacetLibraries makes two libraries with titles whose studios and +// content ratings carry the run's suffix, and returns their ids. +func seedFacetLibraries(t *testing.T) (*pgxpool.Pool, string, int, int) { + t.Helper() + dsn := os.Getenv("SILO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("SILO_TEST_DATABASE_URL is not set") + } + ctx := t.Context() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("connect test database: %v", err) + } + t.Cleanup(pool.Close) + sfx := fmt.Sprintf("%d", time.Now().UnixNano()) + exec := func(sql string, args ...any) { + t.Helper() + if _, err := pool.Exec(ctx, sql, args...); err != nil { + t.Fatalf("exec %s: %v", sql, err) + } + } + var libA, libB int + for _, lib := range []*int{&libA, &libB} { + if err := pool.QueryRow(ctx, `INSERT INTO media_folders (type, name, enabled) VALUES ('movies', $1, true) RETURNING id`, "facet-"+sfx).Scan(lib); err != nil { + t.Fatal(err) + } + } + ids := []string{} + t.Cleanup(func() { + ctx := context.Background() + _, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = ANY($1)`, ids) + _, _ = pool.Exec(ctx, `DELETE FROM media_folders WHERE id = ANY($1)`, []int{libA, libB}) + }) + s := func(name string) string { return name + " " + sfx } + for i, it := range []struct { + lib int + studios []string + rating string + }{ + {libA, []string{s("Warner Bros."), s("Time Warner")}, s("PG")}, + {libA, []string{s("Warner Bros."), " " + s("Trim Co") + " "}, s("PG")}, + {libA, []string{s("Trim Co"), s("100% Studios"), ""}, s("R")}, + {libA, []string{s("1000 Studios"), s("aXb Films")}, ""}, + {libB, []string{s("Only In B")}, s("R")}, + } { + id := fmt.Sprintf("facet-%s-%d", sfx, i) + ids = append(ids, id) + exec(`INSERT INTO media_items (content_id, type, title, status, studios, content_rating) VALUES ($1, 'movie', $1, 'matched', $2, $3)`, id, it.studios, it.rating) + exec(`INSERT INTO media_item_libraries (content_id, media_folder_id) VALUES ($1, $2)`, id, it.lib) + } + return pool, sfx, libA, libB +} + +func TestSearchColumnFacetDB(t *testing.T) { + pool, sfx, libA, libB := seedFacetLibraries(t) + ctx := t.Context() + studios := facetColumns[facetStudio] + cache := newFacetValueCache(time.Minute, facetValueCacheBudget) + search := func(t *testing.T, cache *facetValueCache, column facetColumn, libs []int, s facetSearch) ([]FacetValue, bool) { + t.Helper() + got, hasMore, err := searchColumnFacet(ctx, pool, cache, column, BrowseFilters{LibraryIDs: libs}, "media_items mi", "", s) + if err != nil { + t.Fatal(err) + } + return got, hasMore + } + + t.Run("counts trimmed values per scope", func(t *testing.T) { + got, hasMore := search(t, cache, studios, []int{libA}, facetSearch{Limit: 10}) + want := []FacetValue{ + {Value: "Trim Co " + sfx, Count: 2}, + {Value: "Warner Bros. " + sfx, Count: 2}, + {Value: "100% Studios " + sfx, Count: 1}, + {Value: "1000 Studios " + sfx, Count: 1}, + {Value: "aXb Films " + sfx, Count: 1}, + {Value: "Time Warner " + sfx, Count: 1}, + } + if !slices.Equal(got, want) || hasMore { + t.Fatalf("values = %v hasMore=%v, want %v", got, hasMore, want) + } + }) + + t.Run("ranks prefix then word-start matches", func(t *testing.T) { + got, _ := search(t, cache, studios, []int{libA}, facetSearch{Q: "war", Limit: 10}) + if names := facetValueNames(got); !slices.Equal(names, []string{"Warner Bros. " + sfx, "Time Warner " + sfx}) { + t.Fatalf("names = %v", names) + } + }) + + t.Run("scopes do not share a list", func(t *testing.T) { + got, _ := search(t, cache, studios, []int{libB}, facetSearch{Limit: 10}) + if names := facetValueNames(got); !slices.Equal(names, []string{"Only In B " + sfx}) { + t.Fatalf("library B values = %v", names) + } + }) + + t.Run("scalar column", func(t *testing.T) { + got, _ := search(t, cache, facetColumns[facetContentRating], []int{libA, libB}, facetSearch{Q: "", Limit: 10}) + want := []FacetValue{{Value: "PG " + sfx, Count: 2}, {Value: "R " + sfx, Count: 2}} + if !slices.Equal(got, want) { + t.Fatalf("ratings = %v, want %v", got, want) + } + }) + + t.Run("v2 keeps prefix matches beside ranked values", func(t *testing.T) { + resolver := NewCatalogResolver(NewBrowseRepository(pool), nil) + req := CatalogRequest{Source: CatalogSourceQuery, Query: QueryDefinition{LibraryIDs: []int{libA}}} + got, err := resolver.SearchFacet(ctx, req, AccessFilter{}, "studio", "war", 10) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(got.Matches, []string{"Warner Bros. " + sfx}) || got.HasMore { + t.Errorf("matches = %v hasMore=%v, want the prefix match only", got.Matches, got.HasMore) + } + if names := facetValueNames(got.Values); !slices.Equal(names, []string{"Warner Bros. " + sfx, "Time Warner " + sfx}) { + t.Errorf("values = %v", names) + } + got, err = resolver.SearchFacet(ctx, req, AccessFilter{}, "studio", "t", 1) + if err != nil { + t.Fatal(err) + } + // A-Z, not by title count: "Time Warner" (1 title) before "Trim + // Co" (2 titles). + if !slices.Equal(got.Matches, []string{"Time Warner " + sfx}) || !got.HasMore { + t.Errorf("matches = %v hasMore=%v, want Time Warner and more", got.Matches, got.HasMore) + } + if names := facetValueNames(got.Values); !slices.Equal(names, []string{"Trim Co " + sfx}) || !got.ValuesHasMore { + t.Errorf("values = %v valuesHasMore=%v, want Trim Co and more", names, got.ValuesHasMore) + } + }) + + t.Run("over-budget fallback escapes the query", func(t *testing.T) { + tiny := newFacetValueCache(time.Minute, 1) + for _, tc := range []struct { + s facetSearch + want []FacetValue + }{ + {facetSearch{Q: "100%", Limit: 10}, []FacetValue{{Value: "100% Studios " + sfx, Count: 1}}}, + {facetSearch{Q: "a_b", Limit: 10}, []FacetValue{}}, + {facetSearch{Q: "war", Limit: 10}, []FacetValue{{Value: "Warner Bros. " + sfx, Count: 2}, {Value: "Time Warner " + sfx, Count: 1}}}, + {facetSearch{Q: "bros. " + sfx[:3], Limit: 10}, []FacetValue{{Value: "Warner Bros. " + sfx, Count: 2}}}, + {facetSearch{Q: "", Limit: 2}, []FacetValue{{Value: "Trim Co " + sfx, Count: 2}, {Value: "Warner Bros. " + sfx, Count: 2}}}, + {facetSearch{Q: "1", Limit: 10, Mode: facetSearchPrefix}, []FacetValue{{Value: "100% Studios " + sfx, Count: 1}, {Value: "1000 Studios " + sfx, Count: 1}}}, + } { + got, _ := search(t, tiny, studios, []int{libA}, tc.s) + if !slices.Equal(got, tc.want) { + t.Errorf("%+v: got %v, want %v", tc.s, got, tc.want) + } + } + // Only the oversize marker is cached, so later keystrokes skip the + // list build. + if len(tiny.entries) != 1 || !tiny.lru.next.list.oversize { + t.Errorf("cache holds %d entries; want just the oversize marker", len(tiny.entries)) + } + }) +} diff --git a/internal/catalog/facet_values_test.go b/internal/catalog/facet_values_test.go new file mode 100644 index 0000000000..248664d7b9 --- /dev/null +++ b/internal/catalog/facet_values_test.go @@ -0,0 +1,502 @@ +package catalog + +import ( + "context" + "errors" + "runtime" + "slices" + "sync" + "sync/atomic" + "testing" + "time" +) + +func testFacetList() *facetValueList { + return newFacetValueList([]FacetValue{ + {Value: "Software House", Count: 500}, + {Value: "Time Warner", Count: 100}, + {Value: "Warner Bros. Pictures", Count: 50}, + {Value: "warp films", Count: 50}, + {Value: "Big-War Studios", Count: 5}, + {Value: "Warp", Count: 3}, + {Value: "Universal", Count: 80}, + {Value: "A24", Count: 80}, + {Value: "Lucasfilm/Warhorse", Count: 2}, + }) +} + +func TestFacetValueListRankedSearch(t *testing.T) { + got, hasMore := testFacetList().search(facetSearch{Q: " WAR ", Limit: 10}) + want := []FacetValue{ + // Whole-value prefix matches by count, ties A-Z ignoring case. + {Value: "Warner Bros. Pictures", Count: 50}, + {Value: "warp films", Count: 50}, + {Value: "Warp", Count: 3}, + // Then word starts, by count. "Software" holds "war" mid-word. + {Value: "Time Warner", Count: 100}, + {Value: "Big-War Studios", Count: 5}, + {Value: "Lucasfilm/Warhorse", Count: 2}, + } + if !slices.Equal(got, want) || hasMore { + t.Fatalf("search = %v hasMore=%v, want %v", got, hasMore, want) + } +} + +func TestFacetValueListMultiWordQuery(t *testing.T) { + got, _ := testFacetList().search(facetSearch{Q: "bros. pic", Limit: 10}) + if names := facetValueNames(got); !slices.Equal(names, []string{"Warner Bros. Pictures"}) { + t.Fatalf("search = %v", names) + } +} + +func TestFacetValueListEmptyQueryReturnsMostCommon(t *testing.T) { + got, hasMore := testFacetList().search(facetSearch{Limit: 4}) + want := []string{"Software House", "Time Warner", "A24", "Universal"} + if names := facetValueNames(got); !slices.Equal(names, want) || !hasMore { + t.Fatalf("search = %v hasMore=%v, want %v and more", names, hasMore, want) + } +} + +func TestFacetValueListHasMore(t *testing.T) { + list := testFacetList() + for _, tc := range []struct { + name string + q string + limit int + want []string + hasMore bool + }{ + {name: "exact fit", q: "war", limit: 6, want: []string{"Warner Bros. Pictures", "warp films", "Warp", "Time Warner", "Big-War Studios", "Lucasfilm/Warhorse"}}, + {name: "word matches overflow", q: "war", limit: 4, want: []string{"Warner Bros. Pictures", "warp films", "Warp", "Time Warner"}, hasMore: true}, + {name: "prefix matches overflow", q: "war", limit: 2, want: []string{"Warner Bros. Pictures", "warp films"}, hasMore: true}, + {name: "no match", q: "zzz", limit: 5, want: []string{}}, + {name: "empty fits", q: "", limit: 9, want: []string{"Software House", "Time Warner", "A24", "Universal", "Warner Bros. Pictures", "warp films", "Big-War Studios", "Warp", "Lucasfilm/Warhorse"}}, + } { + t.Run(tc.name, func(t *testing.T) { + got, hasMore := list.search(facetSearch{Q: tc.q, Limit: tc.limit}) + if names := facetValueNames(got); !slices.Equal(names, tc.want) || hasMore != tc.hasMore { + t.Fatalf("search = %v hasMore=%v, want %v hasMore=%v", names, hasMore, tc.want, tc.hasMore) + } + }) + } +} + +func TestFacetValueListPrefixMode(t *testing.T) { + list := testFacetList() + got, hasMore := list.search(facetSearch{Q: "war", Limit: 2, Mode: facetSearchPrefix}) + if names := facetValueNames(got); !slices.Equal(names, []string{"Warner Bros. Pictures", "Warp"}) || !hasMore { + t.Fatalf("prefix search = %v hasMore=%v", names, hasMore) + } + got, hasMore = list.search(facetSearch{Q: "war", Limit: 5, Mode: facetSearchPrefix}) + if names := facetValueNames(got); !slices.Equal(names, []string{"Warner Bros. Pictures", "Warp", "warp films"}) || hasMore { + t.Fatalf("prefix search = %v hasMore=%v", names, hasMore) + } +} + +func TestFacetValueListMatchesLiterally(t *testing.T) { + list := newFacetValueList([]FacetValue{{Value: "100% Studios", Count: 1}, {Value: "Fox_Searchlight", Count: 1}, {Value: "Foxtrot", Count: 1}}) + got, _ := list.search(facetSearch{Q: "100%", Limit: 5}) + if names := facetValueNames(got); !slices.Equal(names, []string{"100% Studios"}) { + t.Fatalf("search 100%% = %v", names) + } + got, _ = list.search(facetSearch{Q: "fox_", Limit: 5}) + if names := facetValueNames(got); !slices.Equal(names, []string{"Fox_Searchlight"}) { + t.Fatalf("search fox_ = %v", names) + } +} + +func TestHasWordStartMatchUnicode(t *testing.T) { + if !hasWordStartMatch("studio ghibli & éditions", "édi") { + t.Fatal("expected a word-start match after &") + } + if hasWordStartMatch("lumière", "ère") { + t.Fatal("matched inside a word") + } +} + +func listOf(n int) *facetValueList { + values := make([]FacetValue, n) + for i := range values { + values[i] = FacetValue{Value: string(rune('a' + i)), Count: 1} + } + return newFacetValueList(values) +} + +func staticLoad(l *facetValueList, calls *atomic.Int32) func(context.Context) (*facetValueList, error) { + return func(context.Context) (*facetValueList, error) { + calls.Add(1) + return l, nil + } +} + +func TestFacetValueCacheKeySeparatesScopes(t *testing.T) { + query := "SELECT name, n FROM x WHERE mi.media_folder_id = ANY($1)" + a, okA := facetCacheKey(query, []any{[]int{1}}) + b, okB := facetCacheKey(query, []any{[]int{2}}) + a2, _ := facetCacheKey(query, []any{[]int{1}}) + other, _ := facetCacheKey(query+" AND TRUE", []any{[]int{1}}) + if !okA || !okB || a == b || a != a2 || a == other { + t.Fatalf("keys: a=%x b=%x a2=%x other=%x", a, b, a2, other) + } + if _, ok := facetCacheKey(query, []any{make(chan int)}); ok { + t.Fatal("an argument that cannot be encoded must make the scope uncacheable") + } + + c := newFacetValueCache(time.Minute, 100) + var calls atomic.Int32 + gotA, _ := c.get(t.Context(), a, staticLoad(listOf(1), &calls)) + gotB, _ := c.get(t.Context(), b, staticLoad(listOf(2), &calls)) + if calls.Load() != 2 || len(gotA.entries) != 1 || len(gotB.entries) != 2 { + t.Fatalf("scopes shared a list: calls=%d a=%d b=%d", calls.Load(), len(gotA.entries), len(gotB.entries)) + } +} + +func TestFacetValueCacheTTL(t *testing.T) { + c := newFacetValueCache(time.Minute, 100) + now := time.Unix(1000, 0) + c.now = func() time.Time { return now } + var calls atomic.Int32 + load := staticLoad(listOf(1), &calls) + for range 3 { + if _, err := c.get(t.Context(), "k", load); err != nil { + t.Fatal(err) + } + } + if calls.Load() != 1 { + t.Fatalf("loads within TTL = %d, want 1", calls.Load()) + } + now = now.Add(time.Minute) + if _, err := c.get(t.Context(), "k", load); err != nil { + t.Fatal(err) + } + if calls.Load() != 2 { + t.Fatalf("loads after TTL = %d, want 2", calls.Load()) + } + if c.used != 1 || len(c.entries) != 1 { + t.Fatalf("expired entry left behind: used=%d entries=%d", c.used, len(c.entries)) + } +} + +func TestFacetValueCacheSingleflight(t *testing.T) { + c := newFacetValueCache(time.Minute, 100) + const callers = 8 + var calls atomic.Int32 + var started sync.WaitGroup + started.Add(callers) + release := make(chan struct{}) + load := func(context.Context) (*facetValueList, error) { + calls.Add(1) + <-release + return listOf(3), nil + } + var wg sync.WaitGroup + results := make([]*facetValueList, callers) + for i := range callers { + wg.Go(func() { + started.Done() + l, err := c.get(t.Context(), "k", load) + if err != nil { + t.Error(err) + } + results[i] = l + }) + } + started.Wait() + // Callers that miss the cache before the load lands join its flight; + // later ones find the stored list. Either way one load serves them all. + for calls.Load() == 0 { + runtime.Gosched() + } + close(release) + wg.Wait() + if calls.Load() != 1 { + t.Fatalf("loads = %d, want 1", calls.Load()) + } + for i, l := range results { + if l != results[0] { + t.Fatalf("caller %d got a different list", i) + } + } +} + +func TestFacetValueCacheCallerCancelDoesNotStopTheBuild(t *testing.T) { + c := newFacetValueCache(time.Minute, 100) + var calls atomic.Int32 + entered := make(chan struct{}) + release := make(chan struct{}) + load := func(ctx context.Context) (*facetValueList, error) { + calls.Add(1) + close(entered) + select { + case <-release: + return listOf(3), nil + case <-ctx.Done(): + return nil, ctx.Err() + } + } + callerCtx, cancel := context.WithCancel(t.Context()) + callerErr := make(chan error, 1) + go func() { + _, err := c.get(callerCtx, "k", load) + callerErr <- err + }() + <-entered + cancel() + if err := <-callerErr; !errors.Is(err, context.Canceled) { + t.Fatalf("caller err = %v, want context.Canceled", err) + } + // The next keystroke joins the build the canceled one started. + next := make(chan *facetValueList, 1) + go func() { + l, err := c.get(t.Context(), "k", load) + if err != nil { + t.Error(err) + } + next <- l + }() + close(release) + if l := <-next; l == nil || len(l.entries) != 3 || calls.Load() != 1 { + t.Fatalf("next caller got %+v after %d loads, want the one full list", l, calls.Load()) + } +} + +func TestFacetValueCacheTimedOutLoadIsNotCached(t *testing.T) { + c := newFacetValueCache(time.Minute, 100) + c.loadTimeout = 10 * time.Millisecond + var calls atomic.Int32 + load := func(ctx context.Context) (*facetValueList, error) { + if calls.Add(1) == 1 { + <-ctx.Done() + // A canceled scan hands back whatever rows it read; it must + // not reach the cache. + return &facetValueList{entries: listOf(1).entries}, ctx.Err() + } + return listOf(3), nil + } + if _, err := c.get(t.Context(), "k", load); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("err = %v, want the load's deadline", err) + } + l, err := c.get(t.Context(), "k", load) + if err != nil || len(l.entries) != 3 || calls.Load() != 2 { + t.Fatalf("list = %+v err=%v calls=%d", l, err, calls.Load()) + } +} + +func TestFacetValueCachePanickingLoadFailsTheFlight(t *testing.T) { + c := newFacetValueCache(time.Minute, 100) + _, err := c.get(t.Context(), "k", func(context.Context) (*facetValueList, error) { panic("boom") }) + if err == nil || len(c.entries) != 0 || len(c.fills) != 0 { + t.Fatalf("err = %v entries=%d fills=%d", err, len(c.entries), len(c.fills)) + } + if l, err := c.get(t.Context(), "k", func(context.Context) (*facetValueList, error) { return listOf(2), nil }); err != nil || len(l.entries) != 2 { + t.Fatalf("after a panic: list=%+v err=%v", l, err) + } +} + +// Misses for different scopes build at most cap(fills) lists at once; a +// build that cannot get a slot within the load timeout fails uncached. +func TestFacetValueCacheBoundsConcurrentFills(t *testing.T) { + c := newFacetValueCache(time.Minute, 100) + c.fills = make(chan struct{}, 1) + entered := make(chan struct{}) + release := make(chan struct{}) + first := make(chan error, 1) + go func() { + _, err := c.get(t.Context(), "a", func(context.Context) (*facetValueList, error) { + close(entered) + <-release + return listOf(1), nil + }) + first <- err + }() + <-entered + + c.loadTimeout = 20 * time.Millisecond + var calls atomic.Int32 + load := func(context.Context) (*facetValueList, error) { + calls.Add(1) + return listOf(3), nil + } + if _, err := c.get(t.Context(), "b", load); !errors.Is(err, context.DeadlineExceeded) || calls.Load() != 0 { + t.Fatalf("second scope: err=%v loads=%d, want a deadline before its load ran", err, calls.Load()) + } + + close(release) + if err := <-first; err != nil { + t.Fatal(err) + } + c.loadTimeout = time.Minute + if l, err := c.get(t.Context(), "b", load); err != nil || len(l.entries) != 3 || calls.Load() != 1 { + t.Fatalf("after the slot freed: list=%+v err=%v loads=%d", l, err, calls.Load()) + } +} + +func TestFacetValueCacheLoadErrorReachesWaitersAndIsNotCached(t *testing.T) { + c := newFacetValueCache(time.Minute, 100) + boom := errors.New("boom") + var calls atomic.Int32 + load := func(context.Context) (*facetValueList, error) { + calls.Add(1) + return nil, boom + } + if _, err := c.get(t.Context(), "k", load); !errors.Is(err, boom) { + t.Fatalf("err = %v", err) + } + if _, err := c.get(t.Context(), "k", load); !errors.Is(err, boom) || calls.Load() != 2 { + t.Fatalf("err = %v calls=%d; a failure must not be cached", err, calls.Load()) + } +} + +func TestFacetValueCacheEvictsLeastRecentlyUsed(t *testing.T) { + c := newFacetValueCache(time.Minute, 5) + var calls atomic.Int32 + get := func(key string, n int) { + t.Helper() + if _, err := c.get(t.Context(), key, staticLoad(listOf(n), &calls)); err != nil { + t.Fatal(err) + } + } + get("a", 2) + get("b", 2) + get("a", 2) // a is now the most recently used + get("c", 2) // over budget: b goes + if calls.Load() != 3 { + t.Fatalf("loads = %d, want 3", calls.Load()) + } + if _, ok := c.entries["b"]; ok { + t.Fatal("b should have been evicted") + } + if _, ok := c.entries["a"]; !ok { + t.Fatal("a was used recently and should stay") + } + if c.used != 4 { + t.Fatalf("used = %d, want 4", c.used) + } +} + +func TestFacetValueCacheSkipsListsOverBudget(t *testing.T) { + c := newFacetValueCache(time.Minute, 5) + var calls atomic.Int32 + get := func(key string, l *facetValueList) { + t.Helper() + if _, err := c.get(t.Context(), key, staticLoad(l, &calls)); err != nil { + t.Fatal(err) + } + } + get("small", listOf(2)) + get("huge", listOf(6)) + get("huge", listOf(6)) + if calls.Load() != 3 { + t.Fatalf("loads = %d, want 3 (an over-budget list is never cached)", calls.Load()) + } + if _, ok := c.entries["small"]; !ok { + t.Fatal("an over-budget list must not evict the others") + } + // The oversize marker weighs one value and is cached like a list. + get("marker", &facetValueList{oversize: true}) + get("marker", &facetValueList{oversize: true}) + if calls.Load() != 4 || c.used != 3 { + t.Fatalf("marker: loads=%d used=%d", calls.Load(), c.used) + } +} + +func TestEscapeRegexLiteral(t *testing.T) { + if got := escapeRegexLiteral("a.b (c)*é"); got != `a\.b\ \(c\)\*é` { + t.Fatalf("escapeRegexLiteral = %q", got) + } +} + +// columnRecordingFetcher records the column searches SearchFacet makes. +type columnRecordingFetcher struct { + recordingFacetFetcher + searches []facetSearch + columns []facetColumn + filters []BrowseFilters +} + +func (f *columnRecordingFetcher) SearchColumnValues(_ context.Context, column facetColumn, filters BrowseFilters, _ string, _ string, search facetSearch) ([]FacetValue, bool, error) { + f.columns = append(f.columns, column) + f.searches = append(f.searches, search) + f.filters = append(f.filters, filters) + return []FacetValue{{Value: "Drama", Count: 3}}, true, nil +} + +// matches keeps the prefix answer every client reads, A-Z and nothing for +// an empty q; values carries the ranked answer. +func TestSearchFacetAnswersMatchesAndValues(t *testing.T) { + facets := &columnRecordingFetcher{} + resolver := &CatalogResolver{browseRepo: &BrowseRepository{}, facets: facets} + req := CatalogRequest{Source: CatalogSourceQuery} + + got, err := resolver.SearchFacet(t.Context(), req, AccessFilter{}, "genre", " ", 500) + if err != nil { + t.Fatal(err) + } + if got.Matches == nil || len(got.Matches) != 0 || got.HasMore { + t.Fatalf("empty q matches = %v hasMore=%v, want none", got.Matches, got.HasMore) + } + if !got.ValuesHasMore || len(got.Values) != 1 || len(facets.searches) != 1 { + t.Fatalf("empty q values = %+v, searches %v", got, facets.searches) + } + if s := facets.searches[0]; s.Q != "" || s.Limit != catalogFacetSearchMaxLimit || s.Mode != facetSearchRanked || facets.columns[0] != facetColumns[facetGenre] { + t.Fatalf("search = %+v column = %+v", s, facets.columns[0]) + } + + facets.searches = nil + got, err = resolver.SearchFacet(t.Context(), req, AccessFilter{}, "genre", " dr ", 10) + if err != nil { + t.Fatal(err) + } + want := []facetSearch{{Q: "dr", Limit: 10, Mode: facetSearchPrefix}, {Q: "dr", Limit: 10, Mode: facetSearchRanked}} + if !slices.Equal(facets.searches, want) { + t.Fatalf("searches = %+v, want %+v", facets.searches, want) + } + if !slices.Equal(got.Matches, []string{"Drama"}) || !got.HasMore || len(got.Values) != 1 || !got.ValuesHasMore { + t.Fatalf("q=dr answer = %+v", got) + } + + facets.searches = nil + for _, facet := range []string{"author", "series"} { + got, err := resolver.SearchFacet(t.Context(), req, AccessFilter{}, facet, "", 10) + if err != nil || got.Matches == nil || len(got.Matches) != 0 || got.Values == nil || len(got.Values) != 0 { + t.Fatalf("%s: got %+v err %v, want empty lists", facet, got, err) + } + } + if len(facets.searches) != 0 { + t.Fatalf("empty q reached the fetcher: %v", facets.searches) + } +} + +// A history facet search without a snapshot must build the same scope +// arguments on every keystroke, or its cached list is never reused. +func TestSearchFacetHistoryScopeIsStableAcrossCalls(t *testing.T) { + resolver := &CatalogResolver{browseRepo: &BrowseRepository{}} + req := CatalogRequest{Source: CatalogSourceHistory} + access := AccessFilter{UserID: 7, ProfileID: "p1"} + keystrokes := func() (*columnRecordingFetcher, bool) { + facets := &columnRecordingFetcher{} + resolver.facets = facets + for _, q := range []string{"d", "dr"} { + if _, err := resolver.SearchFacet(t.Context(), req, access, "genre", q, 10); err != nil { + t.Fatal(err) + } + } + a, _ := facetCacheKey(facets.filters[0].contentSourceSQL, facets.filters[0].contentSourceArgs) + b, _ := facetCacheKey(facets.filters[1].contentSourceSQL, facets.filters[1].contentSourceArgs) + return facets, a == b + } + // Two keystrokes can straddle a minute boundary; three tries in a row + // cannot. + for range 3 { + facets, same := keystrokes() + if !same { + continue + } + snapshot, ok := facets.filters[0].contentSourceArgs[2].(time.Time) + if !ok || snapshot.Before(time.Now()) { + t.Fatalf("default snapshot = %v, want a time that covers now", facets.filters[0].contentSourceArgs[2]) + } + return + } + t.Fatal("history scope arguments differ between keystrokes") +} diff --git a/internal/catalog/history_episode_scope_db_test.go b/internal/catalog/history_episode_scope_db_test.go index 433925f755..1a41c3ea94 100644 --- a/internal/catalog/history_episode_scope_db_test.go +++ b/internal/catalog/history_episode_scope_db_test.go @@ -271,6 +271,13 @@ func TestResolveHistoryEpisodeScope(t *testing.T) { if !slices.Equal(matches.Matches, want) { t.Fatalf("facet search=%v, want %v", matches.Matches, want) } + v1, _, err := resolver.SearchFacetV1(t.Context(), req, access, "genre", "HES-", 10) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(v1, want) { + t.Fatalf("v1 facet search=%v, want %v", v1, want) + } }) } diff --git a/internal/catalog/library_collection_membership.go b/internal/catalog/library_collection_membership.go new file mode 100644 index 0000000000..9f84b7ad46 --- /dev/null +++ b/internal/catalog/library_collection_membership.go @@ -0,0 +1,113 @@ +package catalog + +import ( + "context" + "slices" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/models" +) + +// LibraryCollectionMembership says where a library collection's members come +// from: its stored items, or for a live collection the titles Query matches +// when it is read. +type LibraryCollectionMembership struct { + // Live is true when Query selects the members and false when they are the + // collection's stored items. + Live bool + // Query selects a live collection's members: its rules narrowed to the + // collection's libraries and the requested scope, with the smart item + // limit applied. Its sort and limit decide which titles are members. + Query QueryDefinition + // OutOfScope reports that a live collection has no members because none of + // its libraries is in scope. Query must not run then: its empty LibraryIDs + // would mean every library. + OutOfScope bool + // Sort is the order the members are listed in when the viewer picks none: + // the creator's default from sort_config, otherwise Query's own sort for a + // live collection. It is empty for a stored collection without a default, + // which keeps its curated order. + Sort QuerySort +} + +// ResolveLibraryCollectionMembership decides how a library collection's +// members are read, for every reader that lists them (rails, the collection's +// browse page, collages), so they all agree. +// +// collection_type alone decides, matching how members are written: only a +// smart collection is live, and it never stores items (sync refuses it). Every +// other type keeps its members in library_collection_items, written by manual +// edits and syncs. A query_definition on a non-smart collection is ignored: +// the admin API accepts one on any type, and a PATCH without collection_type +// normalizes it as smart, but neither makes the collection live. +// +// scope lists the libraries the reader may list, nil for no limit. Library +// collections are shared across profiles, so a query with per-profile rules or +// sorts is an error. +func ResolveLibraryCollectionMembership(c *models.LibraryCollection, scope []int) (LibraryCollectionMembership, error) { + defaultSort, hasDefaultSort := ParseCollectionDefaultSort(c.SortConfig, false) + if !IsLiveQueryType(c.CollectionType) { + return LibraryCollectionMembership{Sort: defaultSort}, nil + } + + def, err := parseCatalogCollectionQueryDefinition(c.QueryDefinition) + if err != nil { + return LibraryCollectionMembership{}, err + } + if err := def.ValidateWithOptions(false, false); err != nil { + return LibraryCollectionMembership{}, err + } + m := LibraryCollectionMembership{Live: true, Sort: def.Sort} + if hasDefaultSort { + m.Sort = defaultSort + } + + var collectionLibraries []int + switch { + case len(c.LibraryIDs) > 0: + collectionLibraries = c.LibraryIDs + case c.LibraryID > 0: + collectionLibraries = []int{c.LibraryID} + } + for _, allowed := range [][]int{collectionLibraries, scope} { + if allowed == nil { + continue + } + if len(def.LibraryIDs) == 0 { + def.LibraryIDs = slices.Clone(allowed) + } else { + def.LibraryIDs = intersectInts(def.LibraryIDs, allowed) + } + if len(def.LibraryIDs) == 0 { + m.OutOfScope = true + break + } + } + m.Query = ApplySmartCollectionItemLimit(def) + return m, nil +} + +// PreviewLiveLibraryCollection lists up to limit members of a live collection +// in m.Sort order, with the number of members access admits. It orders them +// the way the collection's browse page does: a sort other than the query's own +// reorders the members without choosing different ones, so a capped query +// keeps the titles its own sort ranks first. The members never depend on the +// viewer's profile, because library collections are shared. +func PreviewLiveLibraryCollection(ctx context.Context, pool *pgxpool.Pool, m LibraryCollectionMembership, access AccessFilter, limit int) ([]*models.MediaItem, int, error) { + if !m.Live || m.OutOfScope { + return []*models.MediaItem{}, 0, nil + } + access = stripCatalogUserScope(access) + executor := &QueryExecutor{Pool: pool, Scope: m.Query.MediaScope} + def := m.Query + if m.Sort != def.Sort { + predicate, args, err := collectionDefinitionPredicate(executor, def, access) + if err != nil { + return nil, 0, err + } + executor.SourceWhere, executor.SourceArgs = predicate, args + def = QueryDefinition{Sort: m.Sort} + } + return executor.Preview(ctx, def, access, limit) +} diff --git a/internal/catalog/library_collection_membership_test.go b/internal/catalog/library_collection_membership_test.go new file mode 100644 index 0000000000..fd9392a2b6 --- /dev/null +++ b/internal/catalog/library_collection_membership_test.go @@ -0,0 +1,179 @@ +package catalog + +import ( + "encoding/json" + "slices" + "testing" + + "github.com/Silo-Server/silo-server/internal/models" +) + +func TestResolveLibraryCollectionMembershipReadsStoredItemsForNonSmartTypes(t *testing.T) { + for _, collectionType := range []string{"manual", "mdblist", "tmdb", "trakt"} { + t.Run(collectionType, func(t *testing.T) { + // A query_definition on a non-smart collection does not make it + // live: its members are the stored items its writers maintain. + c := &models.LibraryCollection{ + CollectionType: collectionType, + LibraryIDs: []int{1}, + QueryDefinition: json.RawMessage(`{"media_scope":"movie","sort":{"field":"year","order":"desc"}}`), + SortConfig: json.RawMessage(`{"field":"title","order":"asc"}`), + } + m, err := ResolveLibraryCollectionMembership(c, nil) + if err != nil { + t.Fatalf("ResolveLibraryCollectionMembership: %v", err) + } + if m.Live { + t.Fatalf("membership = %+v, want stored items", m) + } + if m.Sort != (QuerySort{Field: "title", Order: "asc"}) { + t.Fatalf("sort = %+v, want the sort_config default", m.Sort) + } + }) + } +} + +func TestResolveLibraryCollectionMembershipKeepsCuratedOrderWithoutDefaultSort(t *testing.T) { + m, err := ResolveLibraryCollectionMembership(&models.LibraryCollection{CollectionType: "manual", SortConfig: json.RawMessage(`{}`)}, nil) + if err != nil { + t.Fatalf("ResolveLibraryCollectionMembership: %v", err) + } + if m.Live || m.Sort != (QuerySort{}) { + t.Fatalf("membership = %+v, want stored items in curated order", m) + } +} + +func TestResolveLibraryCollectionMembershipPreparesSmartQuery(t *testing.T) { + c := &models.LibraryCollection{ + CollectionType: "smart", + LibraryIDs: []int{1, 2}, + QueryDefinition: json.RawMessage(`{"media_scope":"movie","library_ids":[2,3],"sort":{"field":"year","order":"desc"}}`), + } + m, err := ResolveLibraryCollectionMembership(c, nil) + if err != nil { + t.Fatalf("ResolveLibraryCollectionMembership: %v", err) + } + if !m.Live || m.OutOfScope { + t.Fatalf("membership = %+v, want a live query", m) + } + if !slices.Equal(m.Query.LibraryIDs, []int{2}) { + t.Fatalf("library_ids = %v, want the query's libraries the collection is bound to", m.Query.LibraryIDs) + } + if m.Query.Limit == nil || *m.Query.Limit != DefaultSmartCollectionItemLimit { + t.Fatalf("limit = %v, want the uncapped smart default", m.Query.Limit) + } + if m.Query.MediaScope != "movie" { + t.Fatalf("media_scope = %q, want movie", m.Query.MediaScope) + } + want := QuerySort{Field: "year", Order: "desc"} + if m.Query.Sort != want || m.Sort != want { + t.Fatalf("query sort %+v, list sort %+v; want both %+v without a sort_config default", m.Query.Sort, m.Sort, want) + } +} + +func TestResolveLibraryCollectionMembershipListsSmartMembersInDefaultSort(t *testing.T) { + c := &models.LibraryCollection{ + CollectionType: "smart", + LibraryID: 4, + QueryDefinition: json.RawMessage(`{"sort":{"field":"year","order":"desc"},"limit":25}`), + SortConfig: json.RawMessage(`{"field":"title","order":"asc"}`), + } + m, err := ResolveLibraryCollectionMembership(c, nil) + if err != nil { + t.Fatalf("ResolveLibraryCollectionMembership: %v", err) + } + // The query's own sort and limit still choose the members; the default + // sort only orders them. + if m.Query.Sort != (QuerySort{Field: "year", Order: "desc"}) || m.Query.Limit == nil || *m.Query.Limit != 25 { + t.Fatalf("query = %+v, want its own sort and limit kept", m.Query) + } + if m.Sort != (QuerySort{Field: "title", Order: "asc"}) { + t.Fatalf("sort = %+v, want the sort_config default", m.Sort) + } + if !slices.Equal(m.Query.LibraryIDs, []int{4}) { + t.Fatalf("library_ids = %v, want the legacy single library", m.Query.LibraryIDs) + } +} + +func TestResolveLibraryCollectionMembershipNarrowsToScope(t *testing.T) { + c := &models.LibraryCollection{ + CollectionType: "smart", + LibraryIDs: []int{1, 2}, + QueryDefinition: json.RawMessage(`{}`), + } + for _, tc := range []struct { + name string + scope []int + want []int + outOfScope bool + }{ + {name: "no scope", scope: nil, want: []int{1, 2}}, + {name: "overlapping scope", scope: []int{2, 5}, want: []int{2}}, + {name: "disjoint scope", scope: []int{5}, outOfScope: true}, + {name: "empty scope", scope: []int{}, outOfScope: true}, + } { + t.Run(tc.name, func(t *testing.T) { + m, err := ResolveLibraryCollectionMembership(c, tc.scope) + if err != nil { + t.Fatalf("ResolveLibraryCollectionMembership: %v", err) + } + if !m.Live || m.OutOfScope != tc.outOfScope { + t.Fatalf("membership = %+v, want live with out_of_scope=%v", m, tc.outOfScope) + } + if !tc.outOfScope && !slices.Equal(m.Query.LibraryIDs, tc.want) { + t.Fatalf("library_ids = %v, want %v", m.Query.LibraryIDs, tc.want) + } + }) + } +} + +func TestResolveLibraryCollectionMembershipHasNoMembersOutsideItsLibraries(t *testing.T) { + // The query names only libraries the collection is not bound to. An empty + // library list would otherwise run the query over every library. + c := &models.LibraryCollection{ + CollectionType: "smart", + LibraryIDs: []int{1}, + QueryDefinition: json.RawMessage(`{"library_ids":[2]}`), + } + m, err := ResolveLibraryCollectionMembership(c, []int{1, 2}) + if err != nil { + t.Fatalf("ResolveLibraryCollectionMembership: %v", err) + } + if !m.Live || !m.OutOfScope { + t.Fatalf("membership = %+v, want a live collection with no members", m) + } + // No query runs, so no database is needed to answer it. + items, total, err := PreviewLiveLibraryCollection(t.Context(), nil, m, AccessFilter{}, 20) + if err != nil || len(items) != 0 || total != 0 { + t.Fatalf("preview = %v (total %d, err %v), want nothing", items, total, err) + } +} + +func TestResolveLibraryCollectionMembershipRunsUnboundSmartQueryEverywhere(t *testing.T) { + // The repository reads a collection without library bindings as an empty + // list, which binds it to nothing rather than to no library. + m, err := ResolveLibraryCollectionMembership(&models.LibraryCollection{CollectionType: "smart", LibraryIDs: []int{}}, nil) + if err != nil { + t.Fatalf("ResolveLibraryCollectionMembership: %v", err) + } + if !m.Live || m.OutOfScope || len(m.Query.LibraryIDs) != 0 { + t.Fatalf("membership = %+v, want a live query over every library", m) + } +} + +func TestResolveLibraryCollectionMembershipRejectsUnusableQueries(t *testing.T) { + for name, raw := range map[string]string{ + "malformed": `{`, + "personalized rule": `{"groups":[{"match":"all","rules":[{"field":"watched","op":"is","value":true}]}]}`, + "personalized sort": `{"sort":{"field":"progress","order":"desc"}}`, + } { + t.Run(name, func(t *testing.T) { + // Library collections are shared across profiles, so their query + // must not depend on who is watching. + c := &models.LibraryCollection{CollectionType: "smart", QueryDefinition: json.RawMessage(raw)} + if m, err := ResolveLibraryCollectionMembership(c, nil); err == nil { + t.Fatalf("membership = %+v, want an error", m) + } + }) + } +} diff --git a/internal/catalog/library_collection_posters.go b/internal/catalog/library_collection_posters.go index 6ce188c37e..9c5900fe74 100644 --- a/internal/catalog/library_collection_posters.go +++ b/internal/catalog/library_collection_posters.go @@ -2,99 +2,27 @@ package catalog import ( "context" - "crypto/sha256" - "encoding/hex" - "errors" "fmt" "log/slog" - "runtime/debug" + "maps" "slices" "strings" - "sync" "time" - "github.com/jackc/pgx/v5" - - "github.com/Silo-Server/silo-server/internal/collage" "github.com/Silo-Server/silo-server/internal/models" ) // A server collection without an uploaded or template poster shows a collage -// of its members' posters. The members it shows depend on the viewer: each -// viewer sees the first members it can access, in collection order, so a -// restricted profile never sees the poster of a title it can't open (#1618). -// -// Each distinct set of source posters is one stored collage, shared by every -// viewer that selects the same posters. Its key is a hash of those posters, so -// a change to the members, their order, or their artwork selects a new collage -// rather than serving a stale one. library_collections.poster_url holds only -// uploaded and template posters. -const ( - // CollectionCollageSourceLimit is the most member posters one collage shows. - CollectionCollageSourceLimit = 4 - - // collectionCollageLayout versions how a collage is composed from its - // sources. Changing the composition must change this value, so collages - // already stored are rebuilt instead of reused. - collectionCollageLayout = "collage-v1" - - // A collage in use is touched at most this often, so reads rarely write. - collectionCollageTouchInterval = 24 * time.Hour - // A collage nobody has read for this long is retired the next time a - // collage of the same collection is built. - collectionCollageUnusedAfter = 7 * 24 * time.Hour - - collageBuildConcurrency = 2 - collageBuildMaxPending = 256 - collageBuildTimeout = 2 * time.Minute - // After a failed build, reads don't retry the same collage for this long. - collageBuildRetryAfter = 10 * time.Minute -) - -// ErrCollectionCollageBeingCollected reports that the artwork revision -// collector is deleting a collage's objects, so it can't be rebuilt yet. -var ErrCollectionCollageBeingCollected = errors.New("collection collage is being collected") - -// CollectionPoster is the poster one viewer sees for a server collection. -type CollectionPoster struct { - Path string - Thumbhash string - // CollageKey names the viewer's collage. It is empty for an uploaded or - // template poster, which every viewer shares. - CollageKey string -} - -// CollectionCollageRef names one stored collage of a collection. -type CollectionCollageRef struct { - CollectionID string - Key string -} - -// CollectionCollage is one stored collage. -type CollectionCollage struct { - CollectionCollageRef - Path string - Thumbhash string - LastUsedAt time.Time -} - -// collectionUsesLiveQuery reports whether the catalog lists the collection's -// members from its query rather than its stored items, as -// resolveLibraryCollectionCursor decides. Such a collection has no collage. +// of its members' posters (collection_collages.go): each viewer sees the first +// members it can access, in collection order, so a restricted profile never +// sees the poster of a title it can't open (#1618). +// library_collections.poster_url holds only uploaded and template posters. + +// collectionUsesLiveQuery reports whether the collection's members come from +// its query rather than its stored items, as ResolveLibraryCollectionMembership +// decides. Such a collection has no collage. func collectionUsesLiveQuery(c *models.LibraryCollection) bool { - return IsLiveQueryType(c.CollectionType) || catalogCollectionUsesLiveQuery(c.QueryDefinition) -} - -// CollectionCollageKey returns the key of the collage composed from sources, -// the member poster paths in the order the collage shows them. -func CollectionCollageKey(sources []string) string { - sum := sha256.New() - sum.Write([]byte(collectionCollageLayout)) - for _, source := range sources { - sum.Write([]byte{0}) - sum.Write([]byte(source)) - } - return hex.EncodeToString(sum.Sum(nil)[:8]) + return IsLiveQueryType(c.CollectionType) } // AssignedCollectionPoster returns the collection's uploaded or template @@ -234,11 +162,8 @@ func (r *LibraryCollectionRepository) SaveCollectionCollage(ctx context.Context, if tag.RowsAffected() == 0 { return ErrLibraryCollectionNotFound } - if _, err := tx.Exec(ctx, ` - DELETE FROM artwork_revision_gc_candidates - WHERE original_path = $1 AND locked_at IS NULL - `, c.Path); err != nil { - return fmt.Errorf("releasing collection collage reservation: %w", err) + if err := releaseCollectionCollageReservation(ctx, tx, c.Path); err != nil { + return err } if err := tx.Commit(ctx); err != nil { return fmt.Errorf("committing collection collage save: %w", err) @@ -262,38 +187,9 @@ func (r *LibraryCollectionRepository) RetireUnusedCollectionCollages(ctx context } // ReserveCollectionCollagePath queues a collage path in the artwork revision -// collector before a collage is built under it, pushing any earlier entry past -// the build. If the build fails or its node dies before SaveCollectionCollage, -// the collector deletes whatever it uploaded; a saved row releases the -// reservation. It reports held when a collector worker is processing the -// path, since it may be deleting objects the build would upload; the build -// must then wait for a later read. +// collector before a collage is built under it; see reserveCollectionCollagePath. func (r *LibraryCollectionRepository) ReserveCollectionCollagePath(ctx context.Context, path string) (held bool, err error) { - tx, err := r.pool.Begin(ctx) - if err != nil { - return false, fmt.Errorf("beginning collection collage reservation: %w", err) - } - defer func() { _ = tx.Rollback(ctx) }() - err = tx.QueryRow(ctx, ` - SELECT locked_at IS NOT NULL - FROM artwork_revision_gc_candidates - WHERE original_path = $1 - FOR UPDATE - `, path).Scan(&held) - switch { - case errors.Is(err, pgx.ErrNoRows): - case err != nil: - return false, fmt.Errorf("checking collection collage reservation: %w", err) - case held: - return true, nil - } - if _, err := tx.Exec(ctx, `SELECT public.queue_collection_poster_objects($1)`, path); err != nil { - return false, fmt.Errorf("reserving collection collage path: %w", err) - } - if err := tx.Commit(ctx); err != nil { - return false, fmt.Errorf("committing collection collage reservation: %w", err) - } - return false, nil + return reserveCollectionCollagePath(ctx, r.pool, path) } // CollectionPosterWidths are the resized variants stored beside every @@ -302,16 +198,6 @@ func (r *LibraryCollectionRepository) ReserveCollectionCollagePath(ctx context.C // together. var CollectionPosterWidths = []int{500, 300} -func splitCollectionCollageRefs(refs []CollectionCollageRef) ([]string, []string) { - ids := make([]string, len(refs)) - keys := make([]string, len(refs)) - for i, ref := range refs { - ids[i] = ref.CollectionID - keys[i] = ref.Key - } - return ids, keys -} - // CollectionPosters returns the poster each collection shows the viewer // described by access, keyed by collection ID. An uploaded or template poster // is the same for every viewer. Otherwise the poster is the collage of the @@ -343,34 +229,7 @@ func (s *LibraryCollectionService) CollectionPosters(ctx context.Context, collec slog.WarnContext(ctx, "collage: failed to select collection collages", "component", "catalog", "error", err) return posters } - refs := make([]CollectionCollageRef, 0, len(sources)) - for _, id := range collageIDs { - if src := sources[id]; len(src) > 0 { - refs = append(refs, CollectionCollageRef{CollectionID: id, Key: CollectionCollageKey(src)}) - } - } - stored, err := s.collections.GetCollectionCollages(ctx, refs) - if err != nil { - slog.WarnContext(ctx, "collage: failed to load collection collages", "component", "catalog", "error", err) - return posters - } - - var touch []CollectionCollageRef - now := time.Now() - for _, ref := range refs { - c, ok := stored[ref] - if !ok || c.Path == "" { - s.buildCollageLater(ref, sources[ref.CollectionID]) - continue - } - posters[ref.CollectionID] = CollectionPoster{Path: c.Path, Thumbhash: c.Thumbhash, CollageKey: ref.Key} - if now.Sub(c.LastUsedAt) > collectionCollageTouchInterval { - touch = append(touch, ref) - } - } - if err := s.collections.TouchCollectionCollages(ctx, touch); err != nil { - slog.DebugContext(ctx, "collage: failed to touch collection collages", "component", "catalog", "error", err) - } + maps.Copy(posters, s.collageSet().serve(ctx, collageIDs, sources)) return posters } @@ -409,130 +268,16 @@ func (s *LibraryCollectionService) PrepareCollectionCollage(ctx context.Context, if err != nil { return err } - src := sources[collectionID] - if len(src) == 0 { - return collage.ErrNotEnoughImages - } - ref := CollectionCollageRef{CollectionID: collectionID, Key: CollectionCollageKey(src)} - stored, err := s.collections.GetCollectionCollages(ctx, []CollectionCollageRef{ref}) - if err != nil { - return err - } - if c, ok := stored[ref]; ok && c.Path != "" { - return nil - } - return s.buildCollage(ctx, ref, src) + return s.collageSet().prepare(ctx, collectionID, sources[collectionID]) } -// buildCollage composes and stores one collage, then retires the collection's -// collages that went unused. Nodes that build the same collage at once write -// the same objects, so the result doesn't depend on which one wins. The path -// stays reserved in the artwork revision collector until the row is saved, so -// a failed build leaves nothing behind. +// buildCollage composes and stores one collage of a server collection. func (s *LibraryCollectionService) buildCollage(ctx context.Context, ref CollectionCollageRef, sources []string) error { - held, err := s.collections.ReserveCollectionCollagePath(ctx, s.CollageGen.CollectionCollagePath(ref.CollectionID, ref.Key)) - if err != nil { - return err - } - if held { - return ErrCollectionCollageBeingCollected - } - path, thumbhash, err := s.CollageGen.ComposeCollectionCollage(ctx, ref.CollectionID, ref.Key, sources) - if err != nil { - return err - } - c := CollectionCollage{CollectionCollageRef: ref, Path: path, Thumbhash: thumbhash} - if err := s.collections.SaveCollectionCollage(ctx, c); err != nil { - return err - } - - if _, err := s.collections.RetireUnusedCollectionCollages(ctx, ref.CollectionID, time.Now().Add(-collectionCollageUnusedAfter)); err != nil { - slog.WarnContext(ctx, "collage: failed to retire unused collages", "component", "catalog", "collection_id", ref.CollectionID, "error", err) - } - return nil -} - -// collageBuildQueue runs the collage builds reads ask for in the background. -// It skips a collage already being built, bounds how many builds wait and -// run, and holds off retrying a collage whose build just failed. A build that -// dies with its node is retried by the next read on any node. -type collageBuildQueue struct { - mu sync.Mutex - // pending maps a collage to the time a read may ask for it again: zero - // while a build runs, the retry time after one failed. - pending map[CollectionCollageRef]time.Time - slots chan struct{} -} - -func (s *LibraryCollectionService) collageQueue() *collageBuildQueue { - s.collageBuildsOnce.Do(func() { - s.collageBuilds = &collageBuildQueue{ - pending: make(map[CollectionCollageRef]time.Time), - slots: make(chan struct{}, collageBuildConcurrency), - } - }) - return s.collageBuilds -} - -// claim reports whether a build of ref may start now and, if so, marks it -// running. -func (q *collageBuildQueue) claim(ref CollectionCollageRef, now time.Time) bool { - q.mu.Lock() - defer q.mu.Unlock() - if retryAt, ok := q.pending[ref]; ok && (retryAt.IsZero() || now.Before(retryAt)) { - return false - } - if len(q.pending) >= collageBuildMaxPending { - for pendingRef, retryAt := range q.pending { - if !retryAt.IsZero() && !now.Before(retryAt) { - delete(q.pending, pendingRef) - } - } - if len(q.pending) >= collageBuildMaxPending { - return false - } - } - q.pending[ref] = time.Time{} - return true + return s.collageSet().build(ctx, ref, sources) } -func (q *collageBuildQueue) finish(ref CollectionCollageRef, failed bool, now time.Time) { - q.mu.Lock() - defer q.mu.Unlock() - if failed { - q.pending[ref] = now.Add(collageBuildRetryAfter) - return - } - delete(q.pending, ref) -} - -// buildCollageLater builds a collage a read found missing, off the request. -func (s *LibraryCollectionService) buildCollageLater(ref CollectionCollageRef, sources []string) { - q := s.collageQueue() - if !q.claim(ref, time.Now()) { - return - } - go func() { - failed := true - defer func() { - if r := recover(); r != nil { - slog.Error("collage: build panic", "component", "catalog", "collection_id", ref.CollectionID, "panic", r, "stack", string(debug.Stack())) - } - q.finish(ref, failed, time.Now()) - }() - q.slots <- struct{}{} - defer func() { <-q.slots }() - - ctx, cancel := context.WithTimeout(context.Background(), collageBuildTimeout) - defer cancel() - err := s.buildCollage(ctx, ref, sources) - switch { - case err == nil: - failed = false - case errors.Is(err, collage.ErrNotEnoughImages): - slog.DebugContext(ctx, "collage: no usable source images", "component", "catalog", "collection_id", ref.CollectionID) - default: - slog.WarnContext(ctx, "collage: background build failed", "component", "catalog", "collection_id", ref.CollectionID, "error", err) - } - }() +// collageSet is the collage machinery over server collections' stored collages. +func (s *LibraryCollectionService) collageSet() collageSet { + s.collageBuildsOnce.Do(func() { s.collageBuilds = newCollageBuildQueue() }) + return collageSet{store: s.collections, gen: s.CollageGen, queue: s.collageBuilds} } diff --git a/internal/catalog/library_collection_posters_db_test.go b/internal/catalog/library_collection_posters_db_test.go index 9901ca300c..506334ee44 100644 --- a/internal/catalog/library_collection_posters_db_test.go +++ b/internal/catalog/library_collection_posters_db_test.go @@ -383,8 +383,9 @@ func TestCollectionCollagesFollowTheViewerDB(t *testing.T) { } }) - t.Run("a collection listed from a query has no collage", func(t *testing.T) { + t.Run("a smart collection has no collage", func(t *testing.T) { queried := *stored + queried.CollectionType = "smart" queried.QueryDefinition = json.RawMessage(`{"match":"all","groups":[]}`) if got, ok := svc.CollectionPosters(ctx, []*models.LibraryCollection{&queried}, unrestricted)[collection.ID]; ok { t.Fatalf("poster = %+v, want none", got) diff --git a/internal/catalog/personal_collection_access.go b/internal/catalog/personal_collection_access.go new file mode 100644 index 0000000000..62582f8e50 --- /dev/null +++ b/internal/catalog/personal_collection_access.go @@ -0,0 +1,89 @@ +package catalog + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/Silo-Server/silo-server/internal/access" +) + +// ErrPersonalCollectionOwnerAccess reports that the owner of another +// profile's personal collection could not be resolved, so the collection +// cannot be read: its members are never shown under the viewer's access alone. +var ErrPersonalCollectionOwnerAccess = errors.New("personal collection owner access is unavailable") + +// PersonalCollectionAccess resolves the content access of a personal +// collection's owner: the libraries and maturity limits the owning profile +// on the same login may see. It is resolved on every read and never cached +// across requests, so an owner's access change applies on every node at once. +type PersonalCollectionAccess interface { + OwnerFilter(ctx context.Context, userID int, ownerProfileID string) (AccessFilter, error) +} + +// PersonalCollectionFilter is the filter viewerProfileID reads a personal +// collection owned by ownerProfileID with: the viewer's filter, further +// limited to what the owner can access (see IntersectAccess). The owner reads +// its own collections with its own filter and no lookup. Any failure to +// resolve another owner is ErrPersonalCollectionOwnerAccess; it never falls +// back to the viewer's filter. +func PersonalCollectionFilter(ctx context.Context, owners PersonalCollectionAccess, viewer AccessFilter, userID int, viewerProfileID, ownerProfileID string) (AccessFilter, error) { + if strings.TrimSpace(ownerProfileID) == "" { + return AccessFilter{}, fmt.Errorf("%w: the collection has no owner", ErrPersonalCollectionOwnerAccess) + } + if ownerProfileID == viewerProfileID { + return viewer, nil + } + if owners == nil || userID <= 0 { + return AccessFilter{}, ErrPersonalCollectionOwnerAccess + } + owner, err := owners.OwnerFilter(ctx, userID, ownerProfileID) + if err != nil { + return AccessFilter{}, fmt.Errorf("%w: %w", ErrPersonalCollectionOwnerAccess, err) + } + return IntersectAccess(viewer, owner), nil +} + +// IntersectAccess limits a viewer's filter to what owner can access as +// well. Only content access combines: allowed libraries intersect (nil is +// unrestricted, a non-nil empty list denies everything), the stricter content +// rating ceiling and the smaller advisory-age limit apply, and either side +// requiring an advisory age hides unadvised titles. Everything else stays the +// viewer's: identity, the viewer's own hidden libraries, presentation, +// playback quality and excluded media types, and the server-wide unrated-title +// policy. The owner's hidden libraries are a browsing preference, not access, +// so they never limit what others see. +// +// Library lists intersect as lists, not per title: a title in two libraries, +// one allowed only to the viewer and the other only to the owner, is hidden +// although each profile can open it. That errs toward hiding; a per-title +// check would need a second library predicate on every catalog read path. +func IntersectAccess(viewer, owner AccessFilter) AccessFilter { + out := viewer + switch { + case owner.AllowedLibraryIDs == nil: + case viewer.AllowedLibraryIDs == nil: + out.AllowedLibraryIDs = append([]int{}, owner.AllowedLibraryIDs...) + default: + out.AllowedLibraryIDs = intersectInts(viewer.AllowedLibraryIDs, owner.AllowedLibraryIDs) + if out.AllowedLibraryIDs == nil { + out.AllowedLibraryIDs = []int{} + } + } + out.MaturityLimits = intersectMaturityLimits(viewer.MaturityLimits, owner.MaturityLimits) + return out +} + +func intersectMaturityLimits(viewer, owner access.MaturityLimits) access.MaturityLimits { + out := access.MaturityLimits{ + MaxContentRating: access.StricterCeiling(viewer.MaxContentRating, owner.MaxContentRating), + AllowUnratedContent: viewer.AllowUnratedContent, + MaxAdvisoryAge: viewer.MaxAdvisoryAge, + } + if owner.MaxAdvisoryAge > 0 && (out.MaxAdvisoryAge == 0 || owner.MaxAdvisoryAge < out.MaxAdvisoryAge) { + out.MaxAdvisoryAge = owner.MaxAdvisoryAge + } + out.RequireAdvisoryAge = viewer.HidesUnadvised() || owner.HidesUnadvised() + return out +} diff --git a/internal/catalog/personal_collection_access_test.go b/internal/catalog/personal_collection_access_test.go new file mode 100644 index 0000000000..fa64011a2b --- /dev/null +++ b/internal/catalog/personal_collection_access_test.go @@ -0,0 +1,199 @@ +package catalog + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/Silo-Server/silo-server/internal/access" +) + +func TestIntersectAccessLibraries(t *testing.T) { + cases := []struct { + name string + viewer, owner []int + want []int + }{ + {"both unrestricted", nil, nil, nil}, + {"restricted owner limits an unrestricted viewer", nil, []int{2, 1}, []int{2, 1}}, + {"restricted viewer keeps its limit under an unrestricted owner", []int{3}, nil, []int{3}}, + {"overlap", []int{1, 2, 3}, []int{3, 2, 9}, []int{2, 3}}, + {"disjoint denies every library", []int{1}, []int{2}, []int{}}, + {"an owner with no library denies every library", nil, []int{}, []int{}}, + {"a viewer with no library stays denied", []int{}, nil, []int{}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := IntersectAccess(AccessFilter{AllowedLibraryIDs: tc.viewer}, AccessFilter{AllowedLibraryIDs: tc.owner}).AllowedLibraryIDs + if (got == nil) != (tc.want == nil) || !reflect.DeepEqual(append([]int{}, got...), append([]int{}, tc.want...)) { + t.Fatalf("AllowedLibraryIDs = %#v, want %#v", got, tc.want) + } + }) + } +} + +func TestIntersectAccessDoesNotAliasInputs(t *testing.T) { + owner := []int{1, 2} + got := IntersectAccess(AccessFilter{}, AccessFilter{AllowedLibraryIDs: owner}) + got.AllowedLibraryIDs[0] = 99 + if owner[0] != 1 { + t.Fatal("the intersected filter shares the owner's library slice") + } +} + +func TestIntersectAccessMaturityLimits(t *testing.T) { + cases := []struct { + name string + viewer, owner access.MaturityLimits + want access.MaturityLimits + }{ + {"no limits", access.MaturityLimits{}, access.MaturityLimits{}, access.MaturityLimits{}}, + { + "a PG owner limits an unrestricted viewer", + access.MaturityLimits{AllowUnratedContent: true}, + access.MaturityLimits{MaxContentRating: "PG"}, + access.MaturityLimits{MaxContentRating: "PG", AllowUnratedContent: true}, + }, + { + "a PG viewer keeps its ceiling under an unrestricted owner", + access.MaturityLimits{MaxContentRating: "PG"}, + access.MaturityLimits{}, + access.MaturityLimits{MaxContentRating: "PG"}, + }, + { + "the lower ceiling wins either way", + access.MaturityLimits{MaxContentRating: "R"}, + access.MaturityLimits{MaxContentRating: "PG-13"}, + access.MaturityLimits{MaxContentRating: "PG-13"}, + }, + { + "an unusable ceiling stays fail-closed", + access.MaturityLimits{MaxContentRating: "PG"}, + access.MaturityLimits{MaxContentRating: "not a rating"}, + access.MaturityLimits{MaxContentRating: "not a rating"}, + }, + { + "the smaller advisory limit wins", + access.MaturityLimits{MaxAdvisoryAge: 16}, + access.MaturityLimits{MaxAdvisoryAge: 12}, + access.MaturityLimits{MaxAdvisoryAge: 12}, + }, + { + "an advisory limit on one side applies", + access.MaturityLimits{}, + access.MaturityLimits{MaxAdvisoryAge: 10}, + access.MaturityLimits{MaxAdvisoryAge: 10}, + }, + { + "either side requiring an advisory age hides unadvised titles", + access.MaturityLimits{MaxAdvisoryAge: 12}, + access.MaturityLimits{MaxAdvisoryAge: 16, RequireAdvisoryAge: true}, + access.MaturityLimits{MaxAdvisoryAge: 12, RequireAdvisoryAge: true}, + }, + { + "a stored requirement without a limit changes nothing", + access.MaturityLimits{MaxAdvisoryAge: 12}, + access.MaturityLimits{RequireAdvisoryAge: true}, + access.MaturityLimits{MaxAdvisoryAge: 12}, + }, + { + "the unrated policy is the viewer's", + access.MaturityLimits{AllowUnratedContent: false}, + access.MaturityLimits{AllowUnratedContent: true, MaxContentRating: "PG"}, + access.MaturityLimits{MaxContentRating: "PG"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := IntersectAccess(AccessFilter{MaturityLimits: tc.viewer}, AccessFilter{MaturityLimits: tc.owner}).MaturityLimits + if got != tc.want { + t.Fatalf("MaturityLimits = %+v, want %+v", got, tc.want) + } + }) + } +} + +func TestIntersectAccessKeepsTheViewersIdentityAndPreferences(t *testing.T) { + library := 4 + viewer := AccessFilter{ + DisabledLibraryIDs: []int{7}, + PresentationLibraryID: &library, + PresentationLanguage: "fr", + MaxPlaybackQuality: "1080p", + UserID: 3, + ProfileID: "viewer", + DeviceID: "device", + ExcludedMediaTypes: []string{"audiobook"}, + AllowedContentIDs: []string{"a"}, + } + owner := AccessFilter{ + DisabledLibraryIDs: []int{8}, + UserID: 3, + ProfileID: "owner", + MaxPlaybackQuality: "480p", + } + got := IntersectAccess(viewer, owner) + want := viewer + if !reflect.DeepEqual(got, want) { + t.Fatalf("IntersectAccess = %+v, want the viewer's filter %+v", got, want) + } +} + +type fakeOwnerAccess struct { + calls []string + filter AccessFilter + err error +} + +func (f *fakeOwnerAccess) OwnerFilter(_ context.Context, userID int, ownerProfileID string) (AccessFilter, error) { + f.calls = append(f.calls, ownerProfileID) + return f.filter, f.err +} + +func TestPersonalCollectionFilter(t *testing.T) { + viewer := AccessFilter{UserID: 3, ProfileID: "viewer", AllowedLibraryIDs: []int{1, 2}} + t.Run("the owner reads with its own filter and no lookup", func(t *testing.T) { + owners := &fakeOwnerAccess{err: errors.New("must not be called")} + got, err := PersonalCollectionFilter(t.Context(), owners, viewer, 3, "viewer", "viewer") + if err != nil || !reflect.DeepEqual(got, viewer) || len(owners.calls) != 0 { + t.Fatalf("got %+v, %v after %d lookups; want the viewer's filter and none", got, err, len(owners.calls)) + } + if _, err := PersonalCollectionFilter(t.Context(), nil, viewer, 3, "viewer", "viewer"); err != nil { + t.Fatalf("an own collection needs no owner access: %v", err) + } + }) + t.Run("another profile's collection is limited to its owner", func(t *testing.T) { + owners := &fakeOwnerAccess{filter: AccessFilter{AllowedLibraryIDs: []int{2, 5}, MaturityLimits: access.MaturityLimits{MaxContentRating: "PG"}}} + got, err := PersonalCollectionFilter(t.Context(), owners, viewer, 3, "viewer", "owner") + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got.AllowedLibraryIDs, []int{2}) || got.MaxContentRating != "PG" || got.ProfileID != "viewer" { + t.Fatalf("got %+v; want libraries [2], ceiling PG, viewer identity", got) + } + if !reflect.DeepEqual(owners.calls, []string{"owner"}) { + t.Fatalf("owner lookups = %v", owners.calls) + } + }) + t.Run("an unresolvable owner fails closed", func(t *testing.T) { + owners := &fakeOwnerAccess{err: errors.New("boom")} + if _, err := PersonalCollectionFilter(t.Context(), owners, viewer, 3, "viewer", "owner"); !errors.Is(err, ErrPersonalCollectionOwnerAccess) { + t.Fatalf("err = %v, want ErrPersonalCollectionOwnerAccess", err) + } + }) + t.Run("without an owner resolver another profile's collection fails closed", func(t *testing.T) { + if _, err := PersonalCollectionFilter(t.Context(), nil, viewer, 3, "viewer", "owner"); !errors.Is(err, ErrPersonalCollectionOwnerAccess) { + t.Fatalf("err = %v, want ErrPersonalCollectionOwnerAccess", err) + } + }) + t.Run("a collection with no recorded owner fails closed", func(t *testing.T) { + owners := &fakeOwnerAccess{} + if _, err := PersonalCollectionFilter(t.Context(), owners, viewer, 3, "viewer", " "); !errors.Is(err, ErrPersonalCollectionOwnerAccess) { + t.Fatalf("err = %v, want ErrPersonalCollectionOwnerAccess", err) + } + if len(owners.calls) != 0 { + t.Fatalf("looked up a blank owner: %v", owners.calls) + } + }) +} diff --git a/internal/catalog/personal_collection_collages.go b/internal/catalog/personal_collection_collages.go new file mode 100644 index 0000000000..d91ab71bcf --- /dev/null +++ b/internal/catalog/personal_collection_collages.go @@ -0,0 +1,594 @@ +package catalog + +import ( + "cmp" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log/slog" + "maps" + "slices" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// A personal collection without an uploaded or imported poster shows a +// collage of its first titles' posters, as a server collection does +// (collection_collages.go). Its titles come from the Postgres user store: +// a hand-picked or imported collection's members in their stored order, or a +// smart collection's first matches in its query order. Callers pass the +// filter the viewer reads the collection with (PersonalCollectionFilter), so +// another profile's shared collection shows only titles both its owner and +// the viewer can see. The collection's display filter does not narrow its +// collage. Collections kept in a per-user SQLite store have no collage. +// +// A smart collection's matches are too costly to read on every list read, so +// a background refresh reads them and records which stored collage each +// viewer's access sees (user_personal_collection_smart_collages). A list read +// serves that record without running the query, and asks for a refresh when +// there is none for the collection's current definition or it is older than +// SmartRefreshInterval, so a new match reaches the collage within that time. + +// ErrPersonalCollectionNotFound reports a personal collection that is gone. +var ErrPersonalCollectionNotFound = errors.New("personal collection not found") + +const ( + // smartCollageScanLimit is how many of a smart collection's first matches + // are read to find its posters. + smartCollageScanLimit = 3 * CollectionCollageSourceLimit + // defaultPersonalCollageRefreshDelay coalesces a burst of changes to one + // collection, such as titles added one at a time, into one build. + defaultPersonalCollageRefreshDelay = 2 * time.Second + // defaultSmartCollageRefreshInterval is how long a smart collection's + // recorded collage is served before a read refreshes it. + defaultSmartCollageRefreshInterval = 15 * time.Minute +) + +// PersonalCollectionCollages serves and builds personal collection collages. +// A nil receiver, or one without a generator, serves and builds nothing. +type PersonalCollectionCollages struct { + pool *pgxpool.Pool + // gen composes and stores collages. It is set once the API router has + // built its poster signer, possibly after scheduled syncs have started + // calling Refresh, so it is read and written atomically; it stays unset + // when artwork storage is not configured. + gen atomic.Pointer[collageGeneratorRef] + // RefreshDelay is how long Refresh waits before reading a collection's + // titles, so later changes in a burst join the same refresh. + RefreshDelay time.Duration + // SmartRefreshInterval is how long a read serves a smart collection's + // recorded collage before it asks for a refresh of the collection's + // matches. + SmartRefreshInterval time.Duration + + queueOnce sync.Once + queue *collageBuildQueue + + refreshMu sync.Mutex + refreshes map[string]personalCollageRefresh +} + +// personalCollageRefresh is a pending Refresh of one collection: the latest +// definition and filter it was asked for. +type personalCollageRefresh struct { + userID int + collection PersonalCollectionDefinition + access AccessFilter +} + +// collageGeneratorRef boxes a CollageGenerator for atomic.Pointer. +type collageGeneratorRef struct{ CollageGenerator } + +// NewPersonalCollectionCollages serves the collages of the personal +// collections in pool's user store, composed and stored by gen. A nil gen +// serves and builds nothing until SetCollageGenerator supplies one. +func NewPersonalCollectionCollages(pool *pgxpool.Pool, gen CollageGenerator) *PersonalCollectionCollages { + p := &PersonalCollectionCollages{ + pool: pool, + RefreshDelay: defaultPersonalCollageRefreshDelay, + SmartRefreshInterval: defaultSmartCollageRefreshInterval, + } + p.SetCollageGenerator(gen) + return p +} + +// SetCollageGenerator sets the generator that composes and stores collages. +// It is safe to call while collages are served and refreshed. +func (p *PersonalCollectionCollages) SetCollageGenerator(gen CollageGenerator) { + if gen == nil { + p.gen.Store(nil) + return + } + p.gen.Store(&collageGeneratorRef{gen}) +} + +// generator returns the collage generator, or nil when there is none. +func (p *PersonalCollectionCollages) generator() CollageGenerator { + if p == nil || p.pool == nil { + return nil + } + if ref := p.gen.Load(); ref != nil { + return ref.CollageGenerator + } + return nil +} + +func (p *PersonalCollectionCollages) enabled() bool { + return p.generator() != nil +} + +func (p *PersonalCollectionCollages) buildQueue() *collageBuildQueue { + p.queueOnce.Do(func() { p.queue = newCollageBuildQueue() }) + return p.queue +} + +func (p *PersonalCollectionCollages) collageSet(userID int) collageSet { + return collageSet{store: personalCollageStore{pool: p.pool, userID: userID}, gen: p.generator(), queue: p.buildQueue()} +} + +// Posters returns the collage each of account userID's collections shows the +// viewer described by access, keyed by collection ID. Pass only collections +// without an uploaded or imported poster. A collage not built yet is left out +// and built in the background; collections with no title the viewer can see +// that has a poster are absent. Smart collections are served from their +// recorded collages without running their queries. +func (p *PersonalCollectionCollages) Posters(ctx context.Context, userID int, collections []PersonalCollectionDefinition, access AccessFilter) map[string]CollectionPoster { + if !p.enabled() || len(collections) == 0 { + return map[string]CollectionPoster{} + } + var members, smart []PersonalCollectionDefinition + for _, c := range collections { + if IsLiveQueryType(c.CollectionType) { + smart = append(smart, c) + } else { + members = append(members, c) + } + } + posters := p.serveSmart(ctx, userID, smart, access) + if len(members) == 0 { + return posters + } + sources, err := p.ListSources(ctx, userID, members, access) + if err != nil { + slog.WarnContext(ctx, "collage: failed to select personal collection collages", "component", "catalog", "error", err) + return posters + } + ids := make([]string, 0, len(members)) + for _, c := range members { + ids = append(ids, c.ID) + } + maps.Copy(posters, p.collageSet(userID).serve(ctx, ids, sources)) + return posters +} + +// serveSmart returns the recorded collage each smart collection shows the +// viewer described by access, and asks for a background refresh of each one +// whose record is missing, made for another definition, or due. +func (p *PersonalCollectionCollages) serveSmart(ctx context.Context, userID int, collections []PersonalCollectionDefinition, access AccessFilter) map[string]CollectionPoster { + posters := make(map[string]CollectionPoster, len(collections)) + if len(collections) == 0 || (access.AllowedLibraryIDs != nil && len(access.AllowedLibraryIDs) == 0) { + return posters + } + ids := make([]string, 0, len(collections)) + for _, c := range collections { + ids = append(ids, c.ID) + } + store := personalCollageStore{pool: p.pool, userID: userID} + recorded, err := store.getSmartCollages(ctx, ids, collageAccessKey(access)) + if err != nil { + slog.WarnContext(ctx, "collage: failed to load smart collection collages", "component", "catalog", "error", err) + return posters + } + var touch []CollectionCollageRef + now := time.Now() + for _, c := range collections { + r, ok := recorded[c.ID] + if !ok || r.definitionKey != smartDefinitionKey(c.QueryDefinition) { + p.refreshSmartLater(userID, c, access) + continue + } + if now.Sub(r.refreshedAt) >= p.SmartRefreshInterval { + p.refreshSmartLater(userID, c, access) + } + if r.collage.Path == "" { + continue + } + posters[c.ID] = CollectionPoster{Path: r.collage.Path, Thumbhash: r.collage.Thumbhash, CollageKey: r.collage.Key} + if now.Sub(r.collage.LastUsedAt) > collectionCollageTouchInterval { + touch = append(touch, r.collage.CollectionCollageRef) + } + } + if err := store.TouchCollectionCollages(ctx, touch); err != nil { + slog.DebugContext(ctx, "collage: failed to touch collection collages", "component", "catalog", "error", err) + } + return posters +} + +// refreshSmartLater runs refreshSmart off the request. Refreshes of one +// collection for one viewer and definition share a run. +func (p *PersonalCollectionCollages) refreshSmartLater(userID int, c PersonalCollectionDefinition, access AccessFilter) { + ref := CollectionCollageRef{CollectionID: c.ID, Key: "smart:" + collageAccessKey(access) + ":" + smartDefinitionKey(c.QueryDefinition)} + p.buildQueue().runLater(ref, func(ctx context.Context) error { return p.refreshSmart(ctx, userID, c, access) }) +} + +// refreshSmart reads smart collection c's first matches the viewer described +// by access can see, builds their collage unless it is stored, and records it +// as the collage that viewer sees. When no match has a poster it records that +// there is none and succeeds: the record holds off the next refresh until +// SmartRefreshInterval, so the build queue keeps no retry marker for it. +func (p *PersonalCollectionCollages) refreshSmart(ctx context.Context, userID int, c PersonalCollectionDefinition, access AccessFilter) error { + sources, err := p.smartSources(ctx, c.QueryDefinition, access) + if err != nil { + return err + } + key := "" + if len(sources) > 0 { + if err := p.collageSet(userID).prepare(ctx, c.ID, sources); err != nil { + return err + } + key = CollectionCollageKey(sources) + } + store := personalCollageStore{pool: p.pool, userID: userID} + return store.saveSmartCollage(ctx, c.ID, collageAccessKey(access), smartDefinitionKey(c.QueryDefinition), key) +} + +// Refresh builds, in the background and after RefreshDelay, the collage the +// viewer described by access sees for c, unless it is already stored. Call it +// when c's titles or definition change, or its poster is removed, so the +// collage is ready before the next read; a read builds a missing collage +// anyway. Refreshes of one collection within the delay share one build, made +// from the latest definition and filter. +func (p *PersonalCollectionCollages) Refresh(userID int, c PersonalCollectionDefinition, access AccessFilter) { + if !p.enabled() { + return + } + p.refreshMu.Lock() + defer p.refreshMu.Unlock() + if p.refreshes == nil { + p.refreshes = make(map[string]personalCollageRefresh) + } + _, pending := p.refreshes[c.ID] + p.refreshes[c.ID] = personalCollageRefresh{userID: userID, collection: c, access: access} + if pending { + return + } + time.AfterFunc(p.RefreshDelay, func() { p.runRefresh(c.ID) }) +} + +func (p *PersonalCollectionCollages) runRefresh(collectionID string) { + p.refreshMu.Lock() + r := p.refreshes[collectionID] + delete(p.refreshes, collectionID) + p.refreshMu.Unlock() + + if IsLiveQueryType(r.collection.CollectionType) { + p.refreshSmartLater(r.userID, r.collection, r.access) + return + } + ctx, cancel := context.WithTimeout(context.Background(), collageBuildTimeout) + defer cancel() + // Posters builds a missing collage through the build queue. + p.Posters(ctx, r.userID, []PersonalCollectionDefinition{r.collection}, r.access) +} + +// ListSources returns, for each of account userID's collections, the poster +// paths of up to CollectionCollageSourceLimit of its titles the viewer +// described by access can see, in the collection's order. Collections with no +// such title are absent. A smart definition that fails is absent too, and its +// error is returned beside the other collections' sources. +func (p *PersonalCollectionCollages) ListSources(ctx context.Context, userID int, collections []PersonalCollectionDefinition, access AccessFilter) (map[string][]string, error) { + sources := make(map[string][]string, len(collections)) + if p == nil || p.pool == nil || len(collections) == 0 || (access.AllowedLibraryIDs != nil && len(access.AllowedLibraryIDs) == 0) { + return sources, nil + } + var memberIDs []string + var smart []PersonalCollectionDefinition + for _, c := range collections { + if IsLiveQueryType(c.CollectionType) { + smart = append(smart, c) + } else { + memberIDs = append(memberIDs, c.ID) + } + } + if len(memberIDs) > 0 { + if err := p.listMemberSources(ctx, userID, memberIDs, access, sources); err != nil { + return sources, err + } + } + // Identical definitions match the same titles; read each once. + byDefinition := make(map[string][]string, len(smart)) + var failures []error + for _, c := range smart { + src, seen := byDefinition[c.QueryDefinition] + if !seen { + var err error + src, err = p.smartSources(ctx, c.QueryDefinition, access) + if err != nil { + failures = append(failures, fmt.Errorf("collection %s: %w", c.ID, err)) + continue + } + byDefinition[c.QueryDefinition] = src + } + if len(src) > 0 { + sources[c.ID] = src + } + } + return sources, errors.Join(failures...) +} + +// listMemberSources adds the sources of hand-picked and imported collections, +// read from their stored members with the predicates their catalog view and +// item counts apply (CountVisiblePersonalCollectionMembers). +func (p *PersonalCollectionCollages) listMemberSources(ctx context.Context, userID int, collectionIDs []string, access AccessFilter, sources map[string][]string) error { + ids := slices.Clone(collectionIDs) + slices.Sort(ids) + sql, args := buildPersonalCollectionCollageSourcesSQL(userID, slices.Compact(ids), access) + rows, err := p.pool.Query(ctx, sql, args...) + if err != nil { + return fmt.Errorf("listing personal collection collage sources: %w", err) + } + defer rows.Close() + for rows.Next() { + var id, path string + if err := rows.Scan(&id, &path); err != nil { + return fmt.Errorf("scanning personal collection collage source: %w", err) + } + sources[id] = append(sources[id], path) + } + if err := rows.Err(); err != nil { + return fmt.Errorf("iterating personal collection collage sources: %w", err) + } + return nil +} + +func buildPersonalCollectionCollageSourcesSQL(userID int, collectionIDs []string, access AccessFilter) (string, []any) { + args := []any{userID, collectionIDs, CollectionCollageSourceLimit} + argIdx := 4 + var memberWhere strings.Builder + for _, condition := range itemAccessConditions(access, &args, &argIdx) { + memberWhere.WriteString("\n\t\t\t AND " + condition) + } + return ` + SELECT c.id, src.poster_path + FROM unnest($2::text[]) WITH ORDINALITY AS c(id, ord) + CROSS JOIN LATERAL ( + SELECT mi.poster_path, upci.position, upci.media_item_id + FROM user_personal_collection_items upci + JOIN media_items mi ON mi.content_id = upci.media_item_id + WHERE upci.user_id = $1 + AND upci.collection_id = c.id + AND upci.sub_item_id = '' + AND mi.poster_path <> '' + AND ` + MangaChapterExclusionWhere("mi") + memberWhere.String() + ` + ORDER BY upci.position, upci.media_item_id + LIMIT $3 + ) src + ORDER BY c.ord, src.position, src.media_item_id`, args +} + +// smartSources reads a smart collection's first matches as its catalog view +// lists them and keeps the posters of the first ones that have one. +func (p *PersonalCollectionCollages) smartSources(ctx context.Context, queryDefinition string, access AccessFilter) ([]string, error) { + var def QueryDefinition + if err := json.Unmarshal([]byte(queryDefinition), &def); err != nil { + return nil, fmt.Errorf("parsing smart collection query: %w", err) + } + def = ApplySmartCollectionItemLimit(def.Normalize()) + if err := def.ValidateWithOptions(true, true); err != nil { + return nil, err + } + items, _, _, err := (&QueryExecutor{Pool: p.pool}).PreviewPage(ctx, def, access, smartCollageScanLimit, 0, false) + if err != nil { + return nil, fmt.Errorf("reading smart collection matches: %w", err) + } + var sources []string + for _, item := range items { + if item.PosterPath != "" { + sources = append(sources, item.PosterPath) + if len(sources) == CollectionCollageSourceLimit { + break + } + } + } + return sources, nil +} + +// personalCollageStore keeps account userID's personal collection collages +// in user_personal_collection_poster_variants. +type personalCollageStore struct { + pool *pgxpool.Pool + userID int +} + +func (s personalCollageStore) GetCollectionCollages(ctx context.Context, refs []CollectionCollageRef) (map[CollectionCollageRef]CollectionCollage, error) { + collages := make(map[CollectionCollageRef]CollectionCollage, len(refs)) + if len(refs) == 0 { + return collages, nil + } + ids, keys := splitCollectionCollageRefs(refs) + rows, err := s.pool.Query(ctx, ` + SELECT v.collection_id, v.variant_key, v.poster_path, v.poster_thumbhash, v.last_used_at + FROM unnest($2::text[], $3::text[]) AS ref(collection_id, variant_key) + JOIN user_personal_collection_poster_variants v + ON v.user_id = $1 AND v.collection_id = ref.collection_id AND v.variant_key = ref.variant_key + `, s.userID, ids, keys) + if err != nil { + return nil, fmt.Errorf("loading personal collection collages: %w", err) + } + defer rows.Close() + for rows.Next() { + var c CollectionCollage + if err := rows.Scan(&c.CollectionID, &c.Key, &c.Path, &c.Thumbhash, &c.LastUsedAt); err != nil { + return nil, fmt.Errorf("scanning personal collection collage: %w", err) + } + collages[c.CollectionCollageRef] = c + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterating personal collection collages: %w", err) + } + return collages, nil +} + +func (s personalCollageStore) TouchCollectionCollages(ctx context.Context, refs []CollectionCollageRef) error { + if len(refs) == 0 { + return nil + } + ids, keys := splitCollectionCollageRefs(refs) + if _, err := s.pool.Exec(ctx, ` + UPDATE user_personal_collection_poster_variants v + SET last_used_at = NOW() + FROM unnest($2::text[], $3::text[]) AS ref(collection_id, variant_key) + WHERE v.user_id = $1 AND v.collection_id = ref.collection_id AND v.variant_key = ref.variant_key + `, s.userID, ids, keys); err != nil { + return fmt.Errorf("touching personal collection collages: %w", err) + } + return nil +} + +// SaveCollectionCollage stores a built collage, replacing one with the same +// key, and in the same transaction releases its path's reservation. It +// returns ErrPersonalCollectionNotFound when the collection is gone, leaving +// the reservation for the collector. +func (s personalCollageStore) SaveCollectionCollage(ctx context.Context, c CollectionCollage) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("beginning personal collection collage save: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + tag, err := tx.Exec(ctx, ` + INSERT INTO user_personal_collection_poster_variants (user_id, collection_id, variant_key, poster_path, poster_thumbhash) + SELECT $1, $2, $3, $4, $5 + WHERE EXISTS (SELECT 1 FROM user_personal_collections WHERE user_id = $1 AND id = $2) + ON CONFLICT (user_id, collection_id, variant_key) DO UPDATE + SET poster_path = EXCLUDED.poster_path, + poster_thumbhash = EXCLUDED.poster_thumbhash, + last_used_at = NOW() + `, s.userID, c.CollectionID, c.Key, c.Path, c.Thumbhash) + if err != nil { + return fmt.Errorf("saving personal collection collage: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrPersonalCollectionNotFound + } + if err := releaseCollectionCollageReservation(ctx, tx, c.Path); err != nil { + return err + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("committing personal collection collage save: %w", err) + } + return nil +} + +func (s personalCollageStore) RetireUnusedCollectionCollages(ctx context.Context, collectionID string, cutoff time.Time) (int, error) { + tag, err := s.pool.Exec(ctx, ` + DELETE FROM user_personal_collection_poster_variants + WHERE user_id = $1 AND collection_id = $2 AND last_used_at < $3 + `, s.userID, collectionID, cutoff) + if err != nil { + return 0, fmt.Errorf("retiring unused personal collection collages: %w", err) + } + return int(tag.RowsAffected()), nil +} + +// smartCollage is the collage recorded for one smart collection and viewer +// access. collage.Path is empty when the viewer can see no match with a +// poster. +type smartCollage struct { + definitionKey string + refreshedAt time.Time + collage CollectionCollage +} + +// getSmartCollages returns the collages recorded for the viewer access +// accessKey names, keyed by collection ID. +func (s personalCollageStore) getSmartCollages(ctx context.Context, collectionIDs []string, accessKey string) (map[string]smartCollage, error) { + rows, err := s.pool.Query(ctx, ` + SELECT sc.collection_id, sc.definition_key, sc.refreshed_at, + COALESCE(v.variant_key, ''), COALESCE(v.poster_path, ''), COALESCE(v.poster_thumbhash, ''), + COALESCE(v.last_used_at, sc.refreshed_at) + FROM user_personal_collection_smart_collages sc + LEFT JOIN user_personal_collection_poster_variants v + ON v.user_id = sc.user_id AND v.collection_id = sc.collection_id AND v.variant_key = sc.variant_key + WHERE sc.user_id = $1 AND sc.access_key = $2 AND sc.collection_id = ANY($3) + `, s.userID, accessKey, collectionIDs) + if err != nil { + return nil, fmt.Errorf("loading smart collection collages: %w", err) + } + defer rows.Close() + recorded := make(map[string]smartCollage, len(collectionIDs)) + for rows.Next() { + var r smartCollage + if err := rows.Scan(&r.collage.CollectionID, &r.definitionKey, &r.refreshedAt, &r.collage.Key, &r.collage.Path, &r.collage.Thumbhash, &r.collage.LastUsedAt); err != nil { + return nil, fmt.Errorf("scanning smart collection collage: %w", err) + } + recorded[r.collage.CollectionID] = r + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterating smart collection collages: %w", err) + } + return recorded, nil +} + +// saveSmartCollage records variantKey, empty for none, as the collage the +// viewer access accessKey names sees for a smart collection's definition. +func (s personalCollageStore) saveSmartCollage(ctx context.Context, collectionID, accessKey, definitionKey, variantKey string) error { + if _, err := s.pool.Exec(ctx, ` + INSERT INTO user_personal_collection_smart_collages (user_id, collection_id, access_key, definition_key, variant_key) + SELECT $1, $2, $3, $4, NULLIF($5, '') + WHERE EXISTS (SELECT 1 FROM user_personal_collections WHERE user_id = $1 AND id = $2) + ON CONFLICT (user_id, collection_id, access_key) DO UPDATE + SET definition_key = EXCLUDED.definition_key, + variant_key = EXCLUDED.variant_key, + refreshed_at = NOW() + `, s.userID, collectionID, accessKey, definitionKey, variantKey); err != nil { + return fmt.Errorf("recording smart collection collage: %w", err) + } + return nil +} + +// collageAccessKey names the access a viewer reads collections with: every +// field of the filter that can change which titles a query matches or which +// posters they show. Equal filters always get the same key. +func collageAccessKey(access AccessFilter) string { + access.DeviceID = "" + access.ImageSize = "" + access.AllowedLibraryIDs = sortedClone(access.AllowedLibraryIDs) + access.DisabledLibraryIDs = sortedClone(access.DisabledLibraryIDs) + access.AllowedContentIDs = sortedClone(access.AllowedContentIDs) + access.ExcludedMediaTypes = sortedClone(access.ExcludedMediaTypes) + encoded, err := json.Marshal(access) + if err != nil { + // Every field marshals; an unexpected failure must still separate filters. + encoded = fmt.Appendf(nil, "%#v", access) + } + return shortHash(encoded) +} + +// sortedClone returns a sorted copy of s, nil when s is nil, so an +// unrestricted list stays distinct from an empty one. +func sortedClone[S ~[]E, E cmp.Ordered](s S) S { + s = slices.Clone(s) + slices.Sort(s) + return s +} + +// smartDefinitionKey names a smart collection's query definition. +func smartDefinitionKey(queryDefinition string) string { + return shortHash([]byte(queryDefinition)) +} + +func shortHash(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:8]) +} + +func (s personalCollageStore) ReserveCollectionCollagePath(ctx context.Context, path string) (bool, error) { + return reserveCollectionCollagePath(ctx, s.pool, path) +} diff --git a/internal/catalog/personal_collection_collages_db_test.go b/internal/catalog/personal_collection_collages_db_test.go new file mode 100644 index 0000000000..4ec25e2c55 --- /dev/null +++ b/internal/catalog/personal_collection_collages_db_test.go @@ -0,0 +1,475 @@ +package catalog + +import ( + "context" + "errors" + "fmt" + "os" + "slices" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/access" + "github.com/Silo-Server/silo-server/internal/userstore" + "github.com/Silo-Server/silo-server/internal/userstore/pgstore" +) + +// fixedOwnerAccess answers one owner filter for every personal collection. +type fixedOwnerAccess struct{ filter AccessFilter } + +func (o fixedOwnerAccess) OwnerFilter(context.Context, int, string) (AccessFilter, error) { + return o.filter, nil +} + +// TestPersonalCollectionCollagesFollowTheViewerDB covers personal collection +// collages on the database: each viewer's collage is built only from titles +// it can see in the collection (for another profile's shared collection, only +// those the owner can see too), a read never composes but builds a missing +// collage in the background, Refresh builds ahead of the next read, and a +// deleted collection hands its collages to the artwork collector. Set +// SILO_TEST_DATABASE_URL to a migrated database to run it. +func TestPersonalCollectionCollagesFollowTheViewerDB(t *testing.T) { + dsn := os.Getenv("SILO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("SILO_TEST_DATABASE_URL is not set") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("connect test database: %v", err) + } + t.Cleanup(pool.Close) + + suffix := time.Now().UnixNano() + prefix := fmt.Sprintf("personal-collage-%d", suffix) + libraryA := seedCollagePosterLibrary(t, pool, prefix+"-a") + libraryB := seedCollagePosterLibrary(t, pool, prefix+"-b") + var userID int + if err := pool.QueryRow(ctx, `INSERT INTO users (username, role) VALUES ($1, 'user') RETURNING id`, prefix).Scan(&userID); err != nil { + t.Fatalf("seed user: %v", err) + } + t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) }) + owner, kid := prefix+"-owner", prefix+"-kid" + if _, err := pool.Exec(ctx, `INSERT INTO user_profiles (id, user_id, name) VALUES ($1, $3, 'owner'), ($2, $3, 'kid')`, owner, kid, userID); err != nil { + t.Fatalf("seed profiles: %v", err) + } + + // Titles sort by name in the same order as their positions, so the smart + // collection below lists them in the manual collection's order. + type title struct { + name string + age int + library int + poster bool + } + titles := []title{ + {"a-r", 17, libraryA, true}, + {"b-pg1", 8, libraryA, true}, + {"c-no-poster", 8, libraryA, false}, + {"d-g", 0, libraryB, true}, + {"e-pg2", 8, libraryA, true}, + {"f-pg3", 8, libraryA, true}, + } + genre := prefix + "-genre" + poster := func(name string) string { return fmt.Sprintf("test/%s-%s/poster/original.webp", prefix, name) } + contentIDs := make([]string, 0, len(titles)) + for _, ti := range titles { + contentID := fmt.Sprintf("%s-%s", prefix, ti.name) + path := "" + if ti.poster { + path = poster(ti.name) + } + if _, err := pool.Exec(ctx, ` + INSERT INTO media_items (content_id, type, title, sort_title, genres, poster_path, content_rating_age) + VALUES ($1, 'movie', $2, $2, ARRAY[$3], $4, $5) + `, contentID, ti.name, genre, path, ti.age); err != nil { + t.Fatalf("seed %s: %v", ti.name, err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM media_items WHERE content_id = $1`, contentID) + }) + if _, err := pool.Exec(ctx, `INSERT INTO media_item_libraries (content_id, media_folder_id) VALUES ($1, $2)`, contentID, ti.library); err != nil { + t.Fatalf("link %s: %v", ti.name, err) + } + contentIDs = append(contentIDs, contentID) + } + + store, err := pgstore.NewPostgresProvider(pool).ForUser(ctx, userID) + if err != nil { + t.Fatalf("open user store: %v", err) + } + manual, err := store.CreateCollection(ctx, userstore.CreateCollectionInput{CreatorProfileID: owner, Name: "Manual", CollectionType: "manual", IsShared: true}) + if err != nil { + t.Fatalf("create manual collection: %v", err) + } + for i, id := range contentIDs { + if err := store.AddCollectionItem(ctx, manual.ID, id, i); err != nil { + t.Fatalf("add %s: %v", id, err) + } + } + smartQuery := fmt.Sprintf(`{"match":"all","groups":[{"match":"all","rules":[{"field":"genre","op":"is","value":%q}]}],"sort":{"field":"title","order":"asc"}}`, genre) + smart, err := store.CreateCollection(ctx, userstore.CreateCollectionInput{CreatorProfileID: owner, Name: "Smart", CollectionType: "smart", QueryDefinition: smartQuery, IsShared: true}) + if err != nil { + t.Fatalf("create smart collection: %v", err) + } + // Deleting the account deletes its collections and queues their + // collages for the artwork collector; drop those entries too. + t.Cleanup(func() { + ctx := context.Background() + _, _ = pool.Exec(ctx, `DELETE FROM user_personal_collections WHERE user_id = $1`, userID) + for _, id := range []string{manual.ID, smart.ID} { + _, _ = pool.Exec(ctx, `DELETE FROM artwork_revision_gc_candidates WHERE original_path LIKE $1`, "collection-images/"+id+"/%") + } + }) + manualDef := PersonalCollectionDefinition{ID: manual.ID, CollectionType: "manual"} + smartDef := PersonalCollectionDefinition{ID: smart.ID, CollectionType: "smart", QueryDefinition: smartQuery} + + unrestricted := AccessFilter{UserID: userID, ProfileID: owner} + pg := AccessFilter{UserID: userID, ProfileID: kid, MaturityLimits: access.MaturityLimits{MaxContentRating: "PG"}} + // The kid reads the owner's shared collection, and the owner may see only + // library A: the kid's filter is both limits together. + kidReadsShared, err := PersonalCollectionFilter(ctx, fixedOwnerAccess{AccessFilter{AllowedLibraryIDs: []int{libraryA}}}, pg, userID, kid, owner) + if err != nil { + t.Fatalf("owner filter: %v", err) + } + noLibraries := AccessFilter{UserID: userID, ProfileID: kid, AllowedLibraryIDs: []int{}} + + gen := &fakeCollageGenerator{} + collages := NewPersonalCollectionCollages(pool, gen) + collages.RefreshDelay = 0 + + t.Run("sources are the first titles the viewer can see that have a poster", func(t *testing.T) { + for name, tc := range map[string]struct { + access AccessFilter + want []string + }{ + "owner": {unrestricted, []string{poster("a-r"), poster("b-pg1"), poster("d-g"), poster("e-pg2")}}, + "PG viewer": {pg, []string{poster("b-pg1"), poster("d-g"), poster("e-pg2"), poster("f-pg3")}}, + "PG viewer of a library A owner": {kidReadsShared, []string{poster("b-pg1"), poster("e-pg2"), poster("f-pg3")}}, + "viewer without libraries at all": {noLibraries, nil}, + } { + sources, err := collages.ListSources(ctx, userID, []PersonalCollectionDefinition{manualDef, smartDef}, tc.access) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + for _, id := range []string{manual.ID, smart.ID} { + if got := sources[id]; !slices.Equal(got, tc.want) { + t.Fatalf("%s: sources of %s = %v, want %v", name, id, got, tc.want) + } + } + } + }) + + kidSources, err := collages.ListSources(ctx, userID, []PersonalCollectionDefinition{manualDef}, kidReadsShared) + if err != nil { + t.Fatalf("kid sources: %v", err) + } + kidRef := CollectionCollageRef{CollectionID: manual.ID, Key: CollectionCollageKey(kidSources[manual.ID])} + collageStore := personalCollageStore{pool: pool, userID: userID} + + t.Run("a read serves no poster and builds the viewer's collage in the background", func(t *testing.T) { + if got, ok := collages.Posters(ctx, userID, []PersonalCollectionDefinition{manualDef}, kidReadsShared)[manual.ID]; ok { + t.Fatalf("first read served %+v before any collage was built", got) + } + waitForStoredCollage(t, collageStore, kidRef) + got := collages.Posters(ctx, userID, []PersonalCollectionDefinition{manualDef}, kidReadsShared)[manual.ID] + if got.CollageKey != kidRef.Key || got.Path != fmt.Sprintf("collection-images/%s/collage/original.%s.webp", manual.ID, kidRef.Key) || got.Thumbhash != "th-"+kidRef.Key { + t.Fatalf("kid poster = %+v, want collage %s", got, kidRef.Key) + } + built := gen.sources(kidRef.Key) + if slices.Contains(built, poster("a-r")) || slices.Contains(built, poster("d-g")) { + t.Fatalf("kid collage built from %v, which holds a title the kid or the owner can't see", built) + } + }) + + t.Run("the owner's collage is its own", func(t *testing.T) { + ownerSources, _ := collages.ListSources(ctx, userID, []PersonalCollectionDefinition{manualDef}, unrestricted) + ownerRef := CollectionCollageRef{CollectionID: manual.ID, Key: CollectionCollageKey(ownerSources[manual.ID])} + if err := prepareCollage(ctx, collages, userID, manualDef, unrestricted); err != nil { + t.Fatalf("prepare: %v", err) + } + got := collages.Posters(ctx, userID, []PersonalCollectionDefinition{manualDef}, unrestricted)[manual.ID] + if got.CollageKey != ownerRef.Key || got.CollageKey == kidRef.Key { + t.Fatalf("owner poster = %+v, want collage %s", got, ownerRef.Key) + } + }) + + t.Run("a viewer who can see no title with a poster gets no collage", func(t *testing.T) { + if got := collages.Posters(ctx, userID, []PersonalCollectionDefinition{manualDef, smartDef}, noLibraries); len(got) != 0 { + t.Fatalf("posters = %+v, want none", got) + } + }) + + t.Run("Refresh builds the collage after a change, ahead of the next read", func(t *testing.T) { + // Moving the last title to the front changes the owner's first four. + if err := store.ReorderCollectionItems(ctx, manual.ID, append([]string{contentIDs[5]}, contentIDs[:5]...)); err != nil { + t.Fatalf("reorder: %v", err) + } + sources, _ := collages.ListSources(ctx, userID, []PersonalCollectionDefinition{manualDef}, unrestricted) + want := []string{poster("f-pg3"), poster("a-r"), poster("b-pg1"), poster("d-g")} + if !slices.Equal(sources[manual.ID], want) { + t.Fatalf("sources after reorder = %v, want %v", sources[manual.ID], want) + } + collages.Refresh(userID, manualDef, unrestricted) + waitForStoredCollage(t, collageStore, CollectionCollageRef{CollectionID: manual.ID, Key: CollectionCollageKey(want)}) + }) + + t.Run("a smart collection's collage follows its query", func(t *testing.T) { + if err := prepareCollage(ctx, collages, userID, smartDef, pg); err != nil { + t.Fatalf("prepare: %v", err) + } + got := collages.Posters(ctx, userID, []PersonalCollectionDefinition{smartDef}, pg)[smart.ID] + want := []string{poster("b-pg1"), poster("d-g"), poster("e-pg2"), poster("f-pg3")} + if got.CollageKey != CollectionCollageKey(want) || !slices.Equal(gen.sources(got.CollageKey), want) { + t.Fatalf("smart poster = %+v built from %v, want %v", got, gen.sources(got.CollageKey), want) + } + }) + + t.Run("a smart collection with no poster to show holds no build-queue slot", func(t *testing.T) { + // The viewer may see only the title without a poster. + noPoster := AccessFilter{UserID: userID, ProfileID: kid, AllowedContentIDs: []string{contentIDs[2]}} + ref := CollectionCollageRef{CollectionID: smart.ID, Key: "smart:" + collageAccessKey(noPoster) + ":" + smartDefinitionKey(smartQuery)} + collages.refreshSmartLater(userID, smartDef, noPoster) + // The empty refresh records that there is no collage and then frees + // its slot: a failure would hold a retry marker for ten minutes, and + // enough of them fill the queue for every other build on the node. + queue := collages.buildQueue() + deadline := time.Now().Add(5 * time.Second) + for !queue.claim(ref, time.Now()) { + if time.Now().After(deadline) { + t.Fatal("the empty smart refresh still holds its build-queue slot") + } + time.Sleep(20 * time.Millisecond) + } + queue.finish(ref, false, time.Now()) + recorded, err := collageStore.getSmartCollages(ctx, []string{smart.ID}, collageAccessKey(noPoster)) + if err != nil { + t.Fatalf("read smart collages: %v", err) + } + if r, ok := recorded[smart.ID]; !ok || r.collage.Path != "" { + t.Fatalf("record = %+v (found %v), want one with no collage", r, ok) + } + if got := collages.Posters(ctx, userID, []PersonalCollectionDefinition{smartDef}, noPoster); len(got) != 0 { + t.Fatalf("posters = %+v, want none", got) + } + }) + + t.Run("without a generator nothing is served or built", func(t *testing.T) { + off := NewPersonalCollectionCollages(pool, nil) + if got := off.Posters(ctx, userID, []PersonalCollectionDefinition{manualDef}, kidReadsShared); len(got) != 0 { + t.Fatalf("posters = %+v, want none", got) + } + if err := prepareCollage(ctx, off, userID, manualDef, unrestricted); err != nil { + t.Fatalf("prepare: %v", err) + } + var nilCollages *PersonalCollectionCollages + nilCollages.Refresh(userID, manualDef, unrestricted) + if got := nilCollages.Posters(ctx, userID, []PersonalCollectionDefinition{manualDef}, unrestricted); len(got) != 0 { + t.Fatalf("nil posters = %+v, want none", got) + } + }) + + t.Run("a collage for a deleted collection is not saved", func(t *testing.T) { + err := collageStore.SaveCollectionCollage(ctx, CollectionCollage{ + CollectionCollageRef: CollectionCollageRef{CollectionID: prefix + "-missing", Key: "k"}, + Path: "user-collection-images/missing/collage/original.k.webp", + }) + if !errors.Is(err, ErrPersonalCollectionNotFound) { + t.Fatalf("err = %v, want ErrPersonalCollectionNotFound", err) + } + }) + + t.Run("deleting a collection hands its collages to the collector", func(t *testing.T) { + kidPath := fmt.Sprintf("collection-images/%s/collage/original.%s.webp", manual.ID, kidRef.Key) + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM artwork_revision_gc_candidates WHERE original_path LIKE $1`, "collection-images/"+manual.ID+"/%") + }) + if err := store.DeleteCollection(ctx, manual.ID); err != nil { + t.Fatalf("delete collection: %v", err) + } + var left int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM user_personal_collection_poster_variants WHERE collection_id = $1`, manual.ID).Scan(&left); err != nil { + t.Fatalf("count collages: %v", err) + } + if left != 0 { + t.Fatalf("%d collages outlived their collection", left) + } + assertCollageQueuedForCollector(t, pool, kidPath) + }) +} + +// waitForStoredCollage waits for a background build to store ref. +func waitForStoredCollage(t *testing.T, store collageStore, ref CollectionCollageRef) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for { + stored, err := store.GetCollectionCollages(context.Background(), []CollectionCollageRef{ref}) + if err != nil { + t.Fatalf("load collage: %v", err) + } + if _, ok := stored[ref]; ok { + return + } + if time.Now().After(deadline) { + t.Fatalf("collage %s was never built", ref.Key) + } + time.Sleep(20 * time.Millisecond) + } +} + +// statementCountTracer counts the statements a pool sends. +type statementCountTracer struct{ statements atomic.Int64 } + +func (t *statementCountTracer) TraceQueryStart(ctx context.Context, _ *pgx.Conn, _ pgx.TraceQueryStartData) context.Context { + t.statements.Add(1) + return ctx +} + +func (*statementCountTracer) TraceQueryEnd(context.Context, *pgx.Conn, pgx.TraceQueryEndData) {} + +// TestPersonalCollectionCollageListReadsSkipSmartQueriesDB pins what a list +// read of smart collections costs: it serves each viewer's stored collage +// with one statement however many smart collections are listed, and never +// runs their queries. Their sources are read in the background, when a +// collection has no collage for the viewer yet or its collage is older than +// SmartRefreshInterval. Set SILO_TEST_DATABASE_URL to a migrated database to +// run it. +func TestPersonalCollectionCollageListReadsSkipSmartQueriesDB(t *testing.T) { + dsn := os.Getenv("SILO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("SILO_TEST_DATABASE_URL is not set") + } + ctx := context.Background() + config, err := pgxpool.ParseConfig(dsn) + if err != nil { + t.Fatalf("parse test database URL: %v", err) + } + tracer := &statementCountTracer{} + config.ConnConfig.Tracer = tracer + pool, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + t.Fatalf("connect test database: %v", err) + } + t.Cleanup(pool.Close) + + prefix := fmt.Sprintf("smart-collage-reads-%d", time.Now().UnixNano()) + library := seedCollagePosterLibrary(t, pool, prefix) + var userID int + if err := pool.QueryRow(ctx, `INSERT INTO users (username, role) VALUES ($1, 'user') RETURNING id`, prefix).Scan(&userID); err != nil { + t.Fatalf("seed user: %v", err) + } + t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) }) + profile := prefix + "-owner" + if _, err := pool.Exec(ctx, `INSERT INTO user_profiles (id, user_id, name) VALUES ($1, $2, 'owner')`, profile, userID); err != nil { + t.Fatalf("seed profile: %v", err) + } + genre := prefix + "-genre" + poster := func(name string) string { return fmt.Sprintf("test/%s-%s/poster/original.webp", prefix, name) } + addTitle := func(name string) { + t.Helper() + contentID := prefix + "-" + name + if _, err := pool.Exec(ctx, ` + INSERT INTO media_items (content_id, type, title, sort_title, genres, poster_path) + VALUES ($1, 'movie', $2, $2, ARRAY[$3], $4) + `, contentID, name, genre, poster(name)); err != nil { + t.Fatalf("seed %s: %v", name, err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM media_items WHERE content_id = $1`, contentID) + }) + if _, err := pool.Exec(ctx, `INSERT INTO media_item_libraries (content_id, media_folder_id) VALUES ($1, $2)`, contentID, library); err != nil { + t.Fatalf("link %s: %v", name, err) + } + } + for _, name := range []string{"b", "c", "d", "e"} { + addTitle(name) + } + + store, err := pgstore.NewPostgresProvider(pool).ForUser(ctx, userID) + if err != nil { + t.Fatalf("open user store: %v", err) + } + t.Cleanup(func() { + ctx := context.Background() + _, _ = pool.Exec(ctx, `DELETE FROM user_personal_collections WHERE user_id = $1`, userID) + _, _ = pool.Exec(ctx, `DELETE FROM artwork_revision_gc_candidates WHERE original_path LIKE $1`, "collection-images/%"+prefix+"%") + }) + // Each collection has its own definition, so none shares another's read. + const smartCount = 8 + smart := make([]PersonalCollectionDefinition, 0, smartCount) + for i := range smartCount { + query := fmt.Sprintf(`{"match":"any","groups":[{"match":"any","rules":[{"field":"genre","op":"is","value":%q},{"field":"genre","op":"is","value":"%s-unused-%d"}]}],"sort":{"field":"title","order":"asc"}}`, genre, prefix, i) + c, err := store.CreateCollection(ctx, userstore.CreateCollectionInput{CreatorProfileID: profile, Name: fmt.Sprintf("Smart %d", i), CollectionType: "smart", QueryDefinition: query}) + if err != nil { + t.Fatalf("create smart collection: %v", err) + } + smart = append(smart, PersonalCollectionDefinition{ID: c.ID, CollectionType: "smart", QueryDefinition: query}) + } + viewer := AccessFilter{UserID: userID, ProfileID: profile} + gen := &fakeCollageGenerator{} + collages := NewPersonalCollectionCollages(pool, gen) + for _, c := range smart { + if err := prepareCollage(ctx, collages, userID, c, viewer); err != nil { + t.Fatalf("prepare %s: %v", c.ID, err) + } + } + first := CollectionCollageKey([]string{poster("b"), poster("c"), poster("d"), poster("e")}) + + t.Run("a list read serves stored collages with one statement", func(t *testing.T) { + before := tracer.statements.Load() + posters := collages.Posters(ctx, userID, smart, viewer) + if got := tracer.statements.Load() - before; got != 1 { + t.Fatalf("listing %d smart collections sent %d statements, want 1", smartCount, got) + } + for _, c := range smart { + if posters[c.ID].CollageKey != first { + t.Fatalf("poster of %s = %+v, want collage %s", c.ID, posters[c.ID], first) + } + } + }) + + t.Run("a new match reaches the collage in the background once it is due", func(t *testing.T) { + addTitle("a") + if got := collages.Posters(ctx, userID, smart[:1], viewer)[smart[0].ID]; got.CollageKey != first { + t.Fatalf("a read before the refresh is due served %+v, want the stored collage %s", got, first) + } + collages.SmartRefreshInterval = 0 + want := CollectionCollageKey([]string{poster("a"), poster("b"), poster("c"), poster("d")}) + deadline := time.Now().Add(10 * time.Second) + for collages.Posters(ctx, userID, smart[:1], viewer)[smart[0].ID].CollageKey != want { + if time.Now().After(deadline) { + t.Fatalf("the collage never moved to %s", want) + } + time.Sleep(20 * time.Millisecond) + } + }) + + t.Run("a changed definition does not serve the old definition's collage", func(t *testing.T) { + changed := smart[1] + changed.QueryDefinition = strings.Replace(changed.QueryDefinition, `"asc"`, `"desc"`, 1) + if got, ok := collages.Posters(ctx, userID, []PersonalCollectionDefinition{changed}, viewer)[changed.ID]; ok { + t.Fatalf("changed definition served %+v before its collage was built", got) + } + }) +} + +// prepareCollage builds, in the foreground, the collage the viewer described +// by access sees for c, unless it is already stored. +func prepareCollage(ctx context.Context, p *PersonalCollectionCollages, userID int, c PersonalCollectionDefinition, access AccessFilter) error { + if !p.enabled() { + return nil + } + if IsLiveQueryType(c.CollectionType) { + return p.refreshSmart(ctx, userID, c, access) + } + sources, err := p.ListSources(ctx, userID, []PersonalCollectionDefinition{c}, access) + if err != nil { + return err + } + return p.collageSet(userID).prepare(ctx, c.ID, sources[c.ID]) +} diff --git a/internal/catalog/personal_collection_collages_test.go b/internal/catalog/personal_collection_collages_test.go new file mode 100644 index 0000000000..294fdbbddd --- /dev/null +++ b/internal/catalog/personal_collection_collages_test.go @@ -0,0 +1,41 @@ +package catalog + +import ( + "fmt" + "sync" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Scheduled syncs can refresh collages before the router supplies the +// generator, so setting it must not race with refreshes. Run with -race. +func TestPersonalCollectionCollagesGeneratorArrivesWhileRefreshing(t *testing.T) { + // The pool never connects: refreshes wait out RefreshDelay first. + pool, err := pgxpool.New(t.Context(), "postgres://silo@127.0.0.1:1/none") + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + p := NewPersonalCollectionCollages(pool, nil) + p.RefreshDelay = time.Hour + if p.enabled() { + t.Fatal("enabled without a generator") + } + var wg sync.WaitGroup + wg.Go(func() { + for i := range 200 { + p.Refresh(1, PersonalCollectionDefinition{ID: fmt.Sprint(i)}, AccessFilter{}) + } + }) + p.SetCollageGenerator(&fakeCollageGenerator{}) + wg.Wait() + if !p.enabled() { + t.Fatal("not enabled after SetCollageGenerator") + } + p.SetCollageGenerator(nil) + if p.enabled() { + t.Fatal("enabled after clearing the generator") + } +} diff --git a/internal/catalog/personal_collection_counts.go b/internal/catalog/personal_collection_counts.go index dadf307f6e..59efdb2d7d 100644 --- a/internal/catalog/personal_collection_counts.go +++ b/internal/catalog/personal_collection_counts.go @@ -5,7 +5,9 @@ import ( "encoding/json" "errors" "fmt" + "maps" "strings" + "sync" "github.com/jackc/pgx/v5/pgxpool" ) @@ -63,20 +65,32 @@ func personalCollectionCountQuery(userID int, c PersonalCollectionDefinition, ac return executor.buildCountQuery(display, access) } -// CountPersonalCollections batches dynamic counts into at most 32 definitions -// per statement and evaluates identical smart definitions once per call. Counts -// remain scoped to this viewer and request; catalog/watch changes are never -// hidden behind a cross-request cache. Failed definitions are absent from the -// returned map so callers can preserve their existing fallback. +// Personal collection counts run as at most personalCollectionCountStatements +// concurrent statements of at most personalCollectionCountBatchSize +// definitions each. One statement evaluates its counts one after another, so a +// single batch makes the list wait for the sum of every smart definition; +// splitting it bounds the wait by the slowest batch while keeping round trips +// per call small. +const ( + personalCollectionCountStatements = 4 + personalCollectionCountBatchSize = 32 +) + +type collectionCountStatement struct { + id, sql string + args []any +} + +// CountPersonalCollections batches dynamic counts, runs the batches +// concurrently, and evaluates identical smart definitions once per call. +// Counts remain scoped to this viewer and request; catalog/watch changes are +// never hidden behind a cross-request cache. Failed definitions are absent +// from the returned map so callers can preserve their existing fallback. func CountPersonalCollections(ctx context.Context, pool *pgxpool.Pool, userID int, collections []PersonalCollectionDefinition, access AccessFilter) (map[string]int, error) { - type countQuery struct { - id, sql string - args []any - } counts := make(map[string]int, len(collections)) seen := make(map[PersonalCollectionDefinition]string, len(collections)) aliases := make(map[string][]string) - var queries []countQuery + var queries []collectionCountStatement var failures []error for _, c := range collections { key := c @@ -93,51 +107,28 @@ func CountPersonalCollections(ctx context.Context, pool *pgxpool.Pool, userID in continue } seen[key] = c.ID - queries = append(queries, countQuery{id: c.ID, sql: sql, args: args}) + queries = append(queries, collectionCountStatement{id: c.ID, sql: sql, args: args}) } - const batchSize = 32 - for start := 0; start < len(queries); start += batchSize { - batch := queries[start:min(start+batchSize, len(queries))] - parts := make([]string, 0, len(batch)) - var args []any - for _, q := range batch { - idArg := len(args) + 1 - args = append(args, q.id) - parts = append(parts, fmt.Sprintf("SELECT $%d::text, (%s)", idArg, rebindSQLPlaceholders(q.sql, len(args)))) - args = append(args, q.args...) - } - rows, err := pool.Query(ctx, strings.Join(parts, " UNION ALL "), args...) - if err == nil { - for rows.Next() { - var id string - var count int - if err = rows.Scan(&id, &count); err != nil { - break - } - counts[id] = count - } - rows.Close() - if err == nil { - err = rows.Err() - } - } - if err == nil { - continue - } - if ctx.Err() != nil { - failures = append(failures, ctx.Err()) - break - } - // A bad persisted definition must not suppress unrelated counts in its - // batch. Retry independently only after a batch fails. - for _, q := range batch { - var count int - if err := pool.QueryRow(ctx, q.sql, q.args...).Scan(&count); err != nil { - failures = append(failures, fmt.Errorf("collection %s: %w", q.id, err)) - continue - } - counts[q.id] = count + if len(queries) > 0 { + batchSize := min(personalCollectionCountBatchSize, (len(queries)+personalCollectionCountStatements-1)/personalCollectionCountStatements) + var ( + mu sync.Mutex + wg sync.WaitGroup + sem = make(chan struct{}, personalCollectionCountStatements) + ) + for start := 0; start < len(queries); start += batchSize { + batch := queries[start:min(start+batchSize, len(queries))] + wg.Go(func() { + sem <- struct{}{} + defer func() { <-sem }() + batchCounts, batchFailures := countPersonalCollectionBatch(ctx, pool, batch) + mu.Lock() + defer mu.Unlock() + maps.Copy(counts, batchCounts) + failures = append(failures, batchFailures...) + }) } + wg.Wait() } for id, copies := range aliases { if count, ok := counts[id]; ok { @@ -148,3 +139,50 @@ func CountPersonalCollections(ctx context.Context, pool *pgxpool.Pool, userID in } return counts, errors.Join(failures...) } + +// countPersonalCollectionBatch evaluates one batch in a single statement. A +// bad persisted definition must not suppress unrelated counts in its batch, so +// a failed batch is retried one definition at a time. +func countPersonalCollectionBatch(ctx context.Context, pool *pgxpool.Pool, batch []collectionCountStatement) (map[string]int, []error) { + counts := make(map[string]int, len(batch)) + parts := make([]string, 0, len(batch)) + var args []any + for _, q := range batch { + idArg := len(args) + 1 + args = append(args, q.id) + parts = append(parts, fmt.Sprintf("SELECT $%d::text, (%s)", idArg, rebindSQLPlaceholders(q.sql, len(args)))) + args = append(args, q.args...) + } + rows, err := pool.Query(ctx, strings.Join(parts, " UNION ALL "), args...) + if err == nil { + for rows.Next() { + var id string + var count int + if err = rows.Scan(&id, &count); err != nil { + break + } + counts[id] = count + } + rows.Close() + if err == nil { + err = rows.Err() + } + } + if err == nil { + return counts, nil + } + if ctx.Err() != nil { + return nil, []error{ctx.Err()} + } + clear(counts) + var failures []error + for _, q := range batch { + var count int + if err := pool.QueryRow(ctx, q.sql, q.args...).Scan(&count); err != nil { + failures = append(failures, fmt.Errorf("collection %s: %w", q.id, err)) + continue + } + counts[q.id] = count + } + return counts, failures +} diff --git a/internal/catalog/personal_collection_counts_db_test.go b/internal/catalog/personal_collection_counts_db_test.go index 7e2cb62a7e..400bb2d065 100644 --- a/internal/catalog/personal_collection_counts_db_test.go +++ b/internal/catalog/personal_collection_counts_db_test.go @@ -204,7 +204,7 @@ func TestCountPersonalCollectionsBatchedDB(t *testing.T) { before = pool.Stat().AcquireCount() counts, err = CountPersonalCollections(ctx, pool, 0, distinct, viewer) queries = pool.Stat().AcquireCount() - before - if err != nil || len(counts) != len(distinct) || queries > 2 { + if err != nil || len(counts) != len(distinct) || queries > personalCollectionCountStatements { t.Fatalf("distinct counts: %d results, %d queries, error %v", len(counts), queries, err) } for id, want := range wants { diff --git a/internal/catalog/playable_targets.go b/internal/catalog/playable_targets.go index d9513968f3..2fc1b6d6e7 100644 --- a/internal/catalog/playable_targets.go +++ b/internal/catalog/playable_targets.go @@ -10,6 +10,7 @@ import ( "github.com/Silo-Server/silo-server/internal/access" "github.com/Silo-Server/silo-server/internal/userstore" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) @@ -332,25 +333,20 @@ func (r *PlayableTargetResolver) ResolveTargets(ctx context.Context, q PlayableT play_content_id `, strings.Join(fileConditions, " AND "), strings.Join(fileConditions, " AND "), strings.Join(fileConditions, " AND ")) - rows, err := r.pool.Query(ctx, query, args...) - if err != nil { - return nil, fmt.Errorf("resolving playable poster targets: %w", err) - } - defer rows.Close() candidates := make(map[string][]string, len(ids)) hints := make(map[string]string, len(ids)) // An episode has one season, so one map serves every card. seasons := make(map[string]*int) - for rows.Next() { + err := r.queryPlayableTargets(ctx, query, args, func(rows pgx.Rows) error { var ord int64 var playContentID string var isHint bool var season *int if err := rows.Scan(&ord, &playContentID, &isHint, &season); err != nil { - return nil, fmt.Errorf("scanning playable poster target: %w", err) + return fmt.Errorf("scanning playable poster target: %w", err) } if ord < 1 || ord > int64(len(keysByOrd)) { - return nil, fmt.Errorf("playable poster target ordinality %d is outside the requested set", ord) + return fmt.Errorf("playable poster target ordinality %d is outside the requested set", ord) } key := keysByOrd[ord-1] if season != nil { @@ -361,12 +357,13 @@ func (r *PlayableTargetResolver) ResolveTargets(ctx context.Context, q PlayableT if _, ok := hints[key]; !ok { hints[key] = playContentID } - continue + return nil } candidates[key] = append(candidates[key], playContentID) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("iterating playable poster targets: %w", err) + return nil + }) + if err != nil { + return nil, fmt.Errorf("resolving playable poster targets: %w", err) } progress := map[string]userstore.WatchProgress{} if q.ProgressStore != nil { @@ -461,3 +458,29 @@ func progressUpdatedAfter(candidate, current string) bool { } return candidate > current } + +// queryPlayableTargets runs a target statement with JIT disabled and scans each +// row. The planner prices the per-card candidate subqueries far above their +// real cost, which crosses jit_above_cost for a page of cards; compiling then +// takes about twice as long as running the query. +func (r *PlayableTargetResolver) queryPlayableTargets(ctx context.Context, query string, args []any, scan func(pgx.Rows) error) error { + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{AccessMode: pgx.ReadOnly}) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }() + if _, err := tx.Exec(ctx, "SET LOCAL jit = off"); err != nil { + return fmt.Errorf("disabling JIT: %w", err) + } + rows, err := tx.Query(ctx, query, args...) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + if err := scan(rows); err != nil { + return err + } + } + return rows.Err() +} diff --git a/internal/catalog/playable_targets_postgres.go b/internal/catalog/playable_targets_postgres.go index da03ecf948..2b9753119c 100644 --- a/internal/catalog/playable_targets_postgres.go +++ b/internal/catalog/playable_targets_postgres.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "strings" + + "github.com/jackc/pgx/v5" ) // resolvePostgresTargets checks an anchor directly, then tries the latest @@ -76,26 +78,22 @@ func (r *PlayableTargetResolver) resolvePostgresTargets(ctx context.Context, arg WHERE target.play_content_id IS NOT NULL `, fileSQL, progress, firstPlayableEpisodeSQL(fileSQL, completedSQL), firstPlayableEpisodeSQL(fileSQL, "")) - rows, err := r.pool.Query(ctx, query, args...) - if err != nil { - return nil, fmt.Errorf("resolving postgres playable targets: %w", err) - } - defer rows.Close() result := make(map[string]PlayableTarget, len(keysByOrd)) - for rows.Next() { + err := r.queryPlayableTargets(ctx, query, args, func(rows pgx.Rows) error { var ord int64 var contentID string var season *int if err := rows.Scan(&ord, &contentID, &season); err != nil { - return nil, fmt.Errorf("scanning postgres playable target: %w", err) + return fmt.Errorf("scanning postgres playable target: %w", err) } if ord < 1 || ord > int64(len(keysByOrd)) { - return nil, fmt.Errorf("postgres playable target ordinality %d is outside the requested set", ord) + return fmt.Errorf("postgres playable target ordinality %d is outside the requested set", ord) } result[keysByOrd[ord-1]] = PlayableTarget{ContentID: contentID, SeasonNumber: season} - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("iterating postgres playable targets: %w", err) + return nil + }) + if err != nil { + return nil, fmt.Errorf("resolving postgres playable targets: %w", err) } return result, nil } diff --git a/internal/catalog/query_builder.go b/internal/catalog/query_builder.go index 9069db333c..dd31561155 100644 --- a/internal/catalog/query_builder.go +++ b/internal/catalog/query_builder.go @@ -1101,9 +1101,34 @@ func (qb *QueryBuilder) buildAddedAtClause(rule QueryRule) (string, error) { } } +// buildReleaseDateClause compares the item's release date: release_date when +// set, else a well-formed first_air_date. The comparison is emitted once per +// source column, ((release_date IS NOT NULL AND cmp) OR (release_date IS NULL +// AND first_air_date cmp)), rather than over their COALESCE, so the +// release_date indexes can serve it. Both forms agree for every NULL +// combination, including under NOT. func (qb *QueryBuilder) buildReleaseDateClause(rule QueryRule) (string, error) { - column := qb.releaseDateFilterExpr() + // Build the comparison once around a marker so both arms reuse the same + // bound arguments, then substitute each source column for the marker. + const column = "\x00release_date\x00" + clause, err := qb.buildReleaseDateComparison(column, rule) + if err != nil { + return "", err + } + if isEpisodeCatalogScope(qb.mediaScope) { + return strings.ReplaceAll(clause, column, qb.alias+".episode_air_date"), nil + } + releaseDate := qb.alias + ".release_date" + trimmedAirDate := fmt.Sprintf("NULLIF(BTRIM(%s.first_air_date), '')", qb.alias) + firstAirDate := fmt.Sprintf("(CASE WHEN %[1]s ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}$' THEN %[1]s::date ELSE NULL END)", trimmedAirDate) + return fmt.Sprintf("((%[1]s IS NOT NULL AND %[2]s) OR (%[1]s IS NULL AND %[3]s))", + releaseDate, + strings.ReplaceAll(clause, column, releaseDate), + strings.ReplaceAll(clause, column, firstAirDate), + ), nil +} +func (qb *QueryBuilder) buildReleaseDateComparison(column string, rule QueryRule) (string, error) { switch rule.Op { case "gt": return qb.buildTypedComparisonClause(column, ">", rule.Value, "date"), nil @@ -1351,19 +1376,6 @@ func (qb *QueryBuilder) releaseDateSortExpr() string { return queryColumnSQL(qb.alias, querySortDefs["release_date"].columnSQL) } -func (qb *QueryBuilder) releaseDateFilterExpr() string { - if isEpisodeCatalogScope(qb.mediaScope) { - return fmt.Sprintf("%s.episode_air_date", qb.alias) - } - firstAirDate := fmt.Sprintf("NULLIF(BTRIM(%s.first_air_date), '')", qb.alias) - return fmt.Sprintf( - `COALESCE(%s.release_date, CASE WHEN %s ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}$' THEN %s::date ELSE NULL END)`, - qb.alias, - firstAirDate, - firstAirDate, - ) -} - func (qb *QueryBuilder) addedAtFilterExpr() string { if isEpisodeCatalogScope(qb.mediaScope) { return fmt.Sprintf("%s.episode_created_at", qb.alias) @@ -1410,6 +1422,21 @@ func (qb *QueryBuilder) addedAtSortPlan() (string, []string, []any, bool) { return "sort_added.added_at", []string{joinSQL}, args, true } + if len(qb.libraryIDs) == 1 { + // The executor's library scope admits only members of this library, + // so the inner join drops no row, and its one (content, library) row + // is the MIN the aggregate below would compute. first_seen_at is NOT + // NULL, so the order matches idx_item_libraries_folder_seen_content + // and a page reads only its own rows instead of aggregating the whole + // library. + joinSQL := fmt.Sprintf( + `JOIN media_item_libraries sort_added ON sort_added.content_id = %s AND sort_added.media_folder_id = %s`, + qb.libraryContentExpr(), + placeholders[0], + ) + return "sort_added.first_seen_at", []string{joinSQL}, args, false + } + joinSQL := fmt.Sprintf( `LEFT JOIN ( SELECT mil.content_id, MIN(mil.first_seen_at) AS added_at diff --git a/internal/catalog/query_builder_test.go b/internal/catalog/query_builder_test.go index 7693e6e52c..340544e50d 100644 --- a/internal/catalog/query_builder_test.go +++ b/internal/catalog/query_builder_test.go @@ -207,8 +207,9 @@ func TestBuild_ReleaseDateInLastComparesDateExpression(t *testing.T) { t.Fatalf("expected no args, got %v", args) } for _, want := range []string{ - "COALESCE(mi.release_date, CASE WHEN NULLIF(BTRIM(mi.first_air_date), '') ~", - ">= (CURRENT_DATE - INTERVAL '1 years')::date", + "(mi.release_date IS NOT NULL AND mi.release_date >= (CURRENT_DATE - INTERVAL '1 years')::date)", + "(mi.release_date IS NULL AND (CASE WHEN NULLIF(BTRIM(mi.first_air_date), '') ~", + "END) >= (CURRENT_DATE - INTERVAL '1 years')::date)", } { if !strings.Contains(clause, want) { t.Fatalf("expected release_date in_last clause to contain %q, got %q", want, clause) @@ -302,8 +303,22 @@ func TestBuildSortPlan_AddedAtUsesScopedLibraryPlaceholdersAfterExistingArgs(t * if len(plan.Joins) != 1 { t.Fatalf("expected one join, got %v", plan.Joins) } - if !strings.Contains(plan.Joins[0], "mil.media_folder_id IN ($4)") { - t.Fatalf("expected offset placeholder in added_at join, got %q", plan.Joins[0]) + if !strings.Contains(plan.Joins[0], "JOIN media_item_libraries sort_added ON sort_added.content_id = mi.content_id AND sort_added.media_folder_id = $4") { + t.Fatalf("expected offset placeholder in single-library added_at join, got %q", plan.Joins[0]) + } + if !strings.Contains(plan.OrderBy, "sort_added.first_seen_at DESC,") { + t.Fatalf("expected the single-library sort to follow first_seen_at, got %q", plan.OrderBy) + } + + plan, err = NewQueryBuilder("mi"). + WithLibraryScope([]int{6, 7}). + WithArgIdx(4). + BuildSortPlan(QuerySort{Field: "added_at", Order: "desc"}) + if err != nil { + t.Fatalf("BuildSortPlan returned error: %v", err) + } + if len(plan.Joins) != 1 || !strings.Contains(plan.Joins[0], "mil.media_folder_id IN ($4, $5)") { + t.Fatalf("expected offset placeholders in multi-library added_at join, got %v", plan.Joins) } } diff --git a/internal/catalog/query_executor_access_test.go b/internal/catalog/query_executor_access_test.go index 2819521785..08b73fde3d 100644 --- a/internal/catalog/query_executor_access_test.go +++ b/internal/catalog/query_executor_access_test.go @@ -134,3 +134,65 @@ func TestSingleEpisodeCatalogLibraryIDFailsClosedOutsideAccess(t *testing.T) { }) } } + +func TestQueryExecutorDeniesQueryLibrariesOutsideAllowedLibraries(t *testing.T) { + tests := []struct { + name string + libraries []int + allowed []int + deny bool + }{ + {name: "disjoint", libraries: []int{7}, allowed: []int{3}, deny: true}, + {name: "nothing allowed", libraries: nil, allowed: []int{}, deny: true}, + {name: "nothing allowed with query libraries", libraries: []int{7}, allowed: []int{}, deny: true}, + {name: "overlap", libraries: []int{3, 7}, allowed: []int{3}}, + {name: "unrestricted", libraries: []int{7}, allowed: nil}, + } + for _, scope := range []string{"movie", "episode"} { + for _, tt := range tests { + t.Run(scope+"/"+tt.name, func(t *testing.T) { + def := QueryDefinition{Match: "all", LibraryIDs: tt.libraries, MediaScope: scope} + sql, _, err := (&QueryExecutor{}).buildPreviewPageSQL(def, AccessFilter{AllowedLibraryIDs: tt.allowed}, 20, 0, false) + if err != nil { + t.Fatal(err) + } + if got := strings.Contains(sql, "1 = 0"); got != tt.deny { + t.Fatalf("denies = %t, want %t:\n%s", got, tt.deny, sql) + } + }) + } + } +} + +// A viewer's allowlist that shares no library with the query admits nothing. +// The intersection is empty then, and an empty library list would otherwise +// mean every library: jellycompat BoxSets, collection items and custom rows +// pass a query's own library_ids straight to the executor. +func TestQueryExecutorMatchesNothingOutsideTheViewersLibraries(t *testing.T) { + disjoint := AccessFilter{AllowedLibraryIDs: []int{1}} + noLibraries := AccessFilter{AllowedLibraryIDs: []int{}, DisabledLibraryIDs: []int{3}} + for _, tc := range []struct { + name string + def QueryDefinition + access AccessFilter + empty bool + }{ + {name: "libraries outside the allowlist", def: QueryDefinition{LibraryIDs: []int{2}}, access: disjoint, empty: true}, + {name: "episode libraries outside the allowlist", def: QueryDefinition{MediaScope: "episode", LibraryIDs: []int{2}}, access: disjoint, empty: true}, + {name: "empty allowlist beside a disabled library", def: QueryDefinition{}, access: noLibraries, empty: true}, + {name: "empty episode allowlist beside a disabled library", def: QueryDefinition{MediaScope: "episode"}, access: noLibraries, empty: true}, + {name: "overlapping libraries", def: QueryDefinition{LibraryIDs: []int{1, 2}}, access: disjoint}, + {name: "overlapping episode libraries", def: QueryDefinition{MediaScope: "episode", LibraryIDs: []int{1, 2}}, access: disjoint}, + } { + t.Run(tc.name, func(t *testing.T) { + executor := &QueryExecutor{Scope: tc.def.MediaScope} + sql, _, err := executor.buildPreviewPageSQL(tc.def, tc.access, 20, 0, false) + if err != nil { + t.Fatalf("build preview SQL: %v", err) + } + if got := strings.Contains(sql, "1 = 0"); got != tc.empty { + t.Fatalf("matches nothing = %v, want %v:\n%s", got, tc.empty, sql) + } + }) + } +} diff --git a/internal/catalog/release_date_rule_db_test.go b/internal/catalog/release_date_rule_db_test.go new file mode 100644 index 0000000000..f404b327ba --- /dev/null +++ b/internal/catalog/release_date_rule_db_test.go @@ -0,0 +1,75 @@ +package catalog + +import ( + "context" + "fmt" + "slices" + "testing" + "time" +) + +// The release_date rule reads release_date when set and otherwise a +// well-formed first_air_date; a release_date outside the range is not rescued +// by an in-range first_air_date. +func TestReleaseDateRuleMatchesEffectiveDateDB(t *testing.T) { + pool := newBatchEquivTestPool(t) + ctx := context.Background() + suffix := time.Now().UnixNano() + + var lib int + if err := pool.QueryRow(ctx, `INSERT INTO media_folders (type, name, enabled) VALUES ('mixed', $1, true) RETURNING id`, + fmt.Sprintf("release-rule-%d", suffix)).Scan(&lib); err != nil { + t.Fatalf("seed folder: %v", err) + } + type seed struct { + name, kind string + releaseDate any + firstAirDate any + match bool + } + seeds := []seed{ + {"release-in", "movie", "2020-06-01", nil, true}, + {"release-out-air-in", "movie", "2010-06-01", "2020-06-01", false}, + {"release-in-air-out", "movie", "2020-06-01", "2010-06-01", true}, + {"air-in", "series", nil, " 2020-06-01 ", true}, + {"air-out", "series", nil, "2010-06-01", false}, + {"air-malformed", "series", nil, "June 2020", false}, + {"air-blank", "series", nil, " ", false}, + {"neither", "series", nil, nil, false}, + } + var ids, want []string + for _, s := range seeds { + id := fmt.Sprintf("release-rule-%d-%s", suffix, s.name) + ids = append(ids, id) + if s.match { + want = append(want, id) + } + batchEquivExec(t, pool, `INSERT INTO media_items (content_id, type, title, release_date, first_air_date) VALUES ($1, $2, $1, $3::date, $4)`, id, s.kind, s.releaseDate, s.firstAirDate) + batchEquivExec(t, pool, `INSERT INTO media_item_libraries (content_id, media_folder_id) VALUES ($1, $2)`, id, lib) + } + t.Cleanup(func() { + batchEquivExec(t, pool, `DELETE FROM media_items WHERE content_id = ANY($1)`, ids) + batchEquivExec(t, pool, `DELETE FROM media_folders WHERE id = $1`, lib) + }) + + executor := &QueryExecutor{Pool: pool} + for _, rule := range []QueryRule{ + {Field: "release_date", Op: "between", Value: []any{"2019-01-01", "2021-12-31"}}, + {Field: "release_date", Op: "gt", Value: "2019-01-01"}, + } { + def := QueryDefinition{LibraryIDs: []int{lib}, Groups: []QueryGroup{{Match: "all", Rules: []QueryRule{rule}}}}.Normalize() + page, err := executor.PreviewCursorPage(ctx, ApplySmartCollectionItemLimit(def), AccessFilter{}, 50, nil, true) + if err != nil { + t.Fatal(err) + } + var got []string + for _, item := range page.Items { + got = append(got, item.ContentID) + } + slices.Sort(got) + slices.Sort(want) + if !slices.Equal(got, want) || page.Total != len(want) { + t.Fatalf("%s matched %v (total %d), want %v", rule.Op, got, page.Total, want) + } + } +} diff --git a/internal/collections/templates/bundles_test.go b/internal/collections/templates/bundles_test.go new file mode 100644 index 0000000000..a5c301b811 --- /dev/null +++ b/internal/collections/templates/bundles_test.go @@ -0,0 +1,55 @@ +package templates + +import ( + "slices" + "testing" +) + +// TestBuiltinBundlesWithTemplatesFollowBundleOrder pins that each built-in +// bundle lists its templates in template_ids order, Discover and Franchise +// templates included, so a client can show a bundle without the template +// catalog. +func TestBuiltinBundlesWithTemplatesFollowBundleOrder(t *testing.T) { + listed := Default.BundlesWithTemplates() + bundles := ListBundles() + if len(listed) != len(bundles) { + t.Fatalf("got %d bundles with templates, want %d", len(listed), len(bundles)) + } + sources := map[Source]bool{} + for i, b := range listed { + if b.ID != bundles[i].ID { + t.Fatalf("bundle %d = %q, want %q", i, b.ID, bundles[i].ID) + } + ids := make([]string, 0, len(b.Templates)) + for _, tmpl := range b.Templates { + ids = append(ids, tmpl.ID) + sources[tmpl.Source] = true + want, _ := Get(tmpl.ID) + if tmpl.Title != want.Title || tmpl.PosterPath != want.PosterPath || tmpl.Featured != want.Featured { + t.Errorf("%s/%s: template differs from the registry", b.ID, tmpl.ID) + } + } + if !slices.Equal(ids, b.TemplateIDs) { + t.Errorf("%s: templates %v, want template_ids %v", b.ID, ids, b.TemplateIDs) + } + } + for _, source := range []Source{SourceTMDBDiscover, SourceTMDBCollection} { + if !sources[source] { + t.Errorf("no bundle lists a %s template", source) + } + } +} + +// TestNeedsSetupOnlyForFranchisePlaceholder pins which built-in template +// creates a collection that cannot sync until an admin sets its source. +func TestNeedsSetupOnlyForFranchisePlaceholder(t *testing.T) { + for _, tmpl := range List() { + want := tmpl.ID == "tmdb_franchise_placeholder" + if got := tmpl.NeedsSetup(); got != want { + t.Errorf("%s: NeedsSetup() = %v, want %v", tmpl.ID, got, want) + } + } + if (Template{Source: SourceTMDBCollection}).NeedsSetup() != true { + t.Error("a TMDB collection template without a spec must need setup") + } +} diff --git a/internal/collections/templates/registry.go b/internal/collections/templates/registry.go index 2ccd37be75..888b2c5292 100644 --- a/internal/collections/templates/registry.go +++ b/internal/collections/templates/registry.go @@ -113,6 +113,26 @@ func (r *Registry) BundleCatalog() BundleCatalog { return BundleCatalog{Bundles: r.ListBundles()} } +// BundlesWithTemplates returns every registered bundle with its templates in +// the bundle's template_ids order. +func (r *Registry) BundlesWithTemplates() []BundleWithTemplates { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]BundleWithTemplates, 0, len(r.bundles)) + for _, b := range r.bundles { + listed := make([]Template, 0, len(b.TemplateIDs)) + for _, id := range b.TemplateIDs { + // RegisterBundle panics on unknown template IDs, so every + // reference resolves and Templates always matches TemplateIDs. + if idx, ok := r.byID[id]; ok { + listed = append(listed, r.templates[idx]) + } + } + out = append(out, BundleWithTemplates{Bundle: b, Templates: listed}) + } + return out +} + // Catalog returns templates grouped by category, in display order. Empty // categories are omitted. Categories not in the configured order appear last // in the order they were first registered. diff --git a/internal/collections/templates/templates.go b/internal/collections/templates/templates.go index 50ab745cbb..bc674011c7 100644 --- a/internal/collections/templates/templates.go +++ b/internal/collections/templates/templates.go @@ -136,6 +136,19 @@ type Bundle struct { TemplateIDs []string `json:"template_ids"` } +// BundleWithTemplates pairs a bundle with its templates in bundle order. +type BundleWithTemplates struct { + Bundle + Templates []Template +} + +// NeedsSetup reports whether applying the template creates a collection that +// cannot sync until an admin sets its source: the TMDB Franchise placeholder +// ships without a TMDB collection ID. +func (t Template) NeedsSetup() bool { + return t.Source == SourceTMDBCollection && (t.TMDBCollection == nil || t.TMDBCollection.CollectionID <= 0) +} + // Catalog is the listing returned by the API: templates grouped by category // in a stable display order for the gallery. type Catalog struct { diff --git a/internal/jellycompat/playback_scrobble.go b/internal/jellycompat/playback_scrobble.go index e8ee954cc5..f0b73db957 100644 --- a/internal/jellycompat/playback_scrobble.go +++ b/internal/jellycompat/playback_scrobble.go @@ -117,16 +117,22 @@ func compatScrobblePosition(playSession *PlaybackSession, upstreamSession *playb // session. An upstream session lives in one node's session manager, and only // that node sends its start or applies its reports, so a node-local lock // covers both single-node and multi-node deployments. +// +// Callers take the lock in the order they asked for it. A sync.Mutex lets a +// caller that arrives just as the lock is released take it ahead of callers +// already waiting, so a position-less Stopped report could stage its stop +// before a progress report queued ahead of it applied that report's position. type compatScrobbleLocks struct { mu sync.Mutex locks map[string]*compatScrobbleLock } +// compatScrobbleLock is one held key's lock. The entry exists only while +// someone owns the lock and is dropped when the last caller releases it. type compatScrobbleLock struct { - sync.Mutex - // holders counts the owner and waiters, so the entry is dropped once - // nobody needs it. - holders int + // waiters are the callers queued behind the owner, oldest first. Unlock + // hands the lock to the first by closing its channel. + waiters []chan struct{} } func (l *compatScrobbleLocks) lock(key string) (unlock func()) { @@ -134,22 +140,36 @@ func (l *compatScrobbleLocks) lock(key string) (unlock func()) { if l.locks == nil { l.locks = make(map[string]*compatScrobbleLock) } - entry := l.locks[key] - if entry == nil { + entry, held := l.locks[key] + var turn chan struct{} + if held { + turn = make(chan struct{}) + entry.waiters = append(entry.waiters, turn) + } else { entry = &compatScrobbleLock{} l.locks[key] = entry } - entry.holders++ l.mu.Unlock() - entry.Lock() + if turn != nil { + <-turn + } + released := false return func() { - entry.Unlock() l.mu.Lock() - entry.holders-- - if entry.holders == 0 { + defer l.mu.Unlock() + // A second release would drop or hand on a lock someone else now + // owns, so fail loudly as sync.Mutex does. + if released { + panic("jellycompat: scrobble lock released twice") + } + released = true + if len(entry.waiters) == 0 { delete(l.locks, key) + return } - l.mu.Unlock() + next := entry.waiters[0] + entry.waiters = entry.waiters[1:] + close(next) } } diff --git a/internal/jellycompat/playback_scrobble_test.go b/internal/jellycompat/playback_scrobble_test.go index 964778da9b..3e4357b4dd 100644 --- a/internal/jellycompat/playback_scrobble_test.go +++ b/internal/jellycompat/playback_scrobble_test.go @@ -606,7 +606,7 @@ func (l *compatScrobbleLocks) holdersFor(key string) int { l.mu.Lock() defer l.mu.Unlock() if entry := l.locks[key]; entry != nil { - return entry.holders + return 1 + len(entry.waiters) } return 0 } @@ -631,6 +631,61 @@ func waitForCompatScrobbleHolders(t *testing.T, h *PlaybackHandler, upstreamID s } } +// Callers queued on an upstream session's scrobble lock take it in the order +// they queued, so a Stopped report cannot overtake a progress report that was +// already waiting and stage a stop from the position before it. +func TestCompatScrobbleLocksServeWaitersInArrivalOrder(t *testing.T) { + h := &PlaybackHandler{} + locks := &h.compatScrobbleLocks + unlock := locks.lock("upstream") + const waiters = 8 + order := make(chan int, waiters) + released := make(chan struct{}, waiters) + for i := range waiters { + go func() { + release := locks.lock("upstream") + order <- i + release() + released <- struct{}{} + }() + // Queue the next caller only once this one is waiting. + waitForCompatScrobbleHolders(t, h, "upstream", i+2) + } + unlock() + + for want := range waiters { + if got := <-order; got != want { + t.Fatalf("waiter %d took the lock in position %d, want arrival order", got, want) + } + } + for range waiters { + <-released + } + if n := locks.holdersFor("upstream"); n != 0 { + t.Fatalf("lock entry kept %d holders after every caller released it", n) + } +} + +// Releasing the same scrobble lock twice panics instead of dropping the entry +// a later caller now owns, which would let a third caller in beside it. +func TestCompatScrobbleLocksPanicOnDoubleRelease(t *testing.T) { + var locks compatScrobbleLocks + unlock := locks.lock("upstream") + unlock() + relock := locks.lock("upstream") + defer relock() + + defer func() { + if recover() == nil { + t.Fatal("second release of a scrobble lock did not panic") + } + if n := locks.holdersFor("upstream"); n != 1 { + t.Fatalf("lock entry has %d holders after a rejected double release, want the new owner", n) + } + }() + unlock() +} + func scrobbleAt(action string, seconds float64) compatScrobbleCall { return compatScrobbleCall{action: action, event: watchsync.ScrobbleEvent{PositionSeconds: seconds}} } diff --git a/internal/metadata/artwork_reconcile.go b/internal/metadata/artwork_reconcile.go index 64a6b397c5..1c7fcfa041 100644 --- a/internal/metadata/artwork_reconcile.go +++ b/internal/metadata/artwork_reconcile.go @@ -317,16 +317,16 @@ func artworkSweepSurfaces() []artworkSweepSurface { {name: "person photos", table: peopleTable, keyCols: []artworkSweepKey{int64SweepKey("id")}, pathCol: "photo_path", sourceCol: "photo_source_path", clearSet: plainClear("photo_path")}, // Admin/user uploads: no re-downloadable source. Clearing falls back - // to the generated collage (admin collections), the generated poster - // (user collections), or the default tile (library posters); admins - // re-upload anything they want back. alwaysVerify protects surviving - // uploads from blind bulk resets. + // to the generated collage (collections) or the default tile (library + // posters); admins re-upload anything they want back. alwaysVerify + // protects surviving uploads from blind bulk resets. {name: "collection posters", table: "library_collections", keyCols: []artworkSweepKey{textSweepKey("id")}, pathCol: "poster_url", clearSet: `poster_url = '', poster_thumbhash = '', poster_auto_generated = FALSE, poster_from_template = FALSE, updated_at = NOW()`, alwaysVerify: true}, {name: "collection backdrops", table: "library_collections", keyCols: []artworkSweepKey{textSweepKey("id")}, pathCol: "backdrop_url", clearSet: `backdrop_url = '', backdrop_thumbhash = '', updated_at = NOW()`, alwaysVerify: true}, // Generated collection collages. A cleared row reads as a missing // collage, which the next viewer's read rebuilds. {name: "collection collages", table: "library_collection_poster_variants", keyCols: []artworkSweepKey{textSweepKey("collection_id"), textSweepKey("variant_key")}, pathCol: "poster_path", clearSet: `poster_path = '', poster_thumbhash = ''`, alwaysVerify: true}, {name: "user collection posters", table: "user_personal_collections", keyCols: []artworkSweepKey{textSweepKey("id")}, pathCol: "poster_url", clearSet: `poster_url = '', poster_thumbhash = '', updated_at = NOW()`, alwaysVerify: true}, + {name: "user collection collages", table: "user_personal_collection_poster_variants", keyCols: []artworkSweepKey{int64SweepKey("user_id"), textSweepKey("collection_id"), textSweepKey("variant_key")}, pathCol: "poster_path", clearSet: `poster_path = '', poster_thumbhash = ''`, alwaysVerify: true}, {name: "library posters", table: "media_folders", keyCols: []artworkSweepKey{int64SweepKey("id")}, pathCol: posterPathColumn, clearSet: `poster_path = ''`, alwaysVerify: true}, } } diff --git a/internal/notifications/collection_membership_test.go b/internal/notifications/collection_membership_test.go new file mode 100644 index 0000000000..c1f490945e --- /dev/null +++ b/internal/notifications/collection_membership_test.go @@ -0,0 +1,29 @@ +package notifications + +import ( + "database/sql" + "testing" + + "github.com/Silo-Server/silo-server/internal/userdb" + "github.com/Silo-Server/silo-server/internal/userstore/storetest" +) + +// TestInterestTrackingStorePreservesCollectionMembership keeps Add to +// collection's membership read working through the production decorator: +// without it, listCollections?contains_item answers 501 on every backend. +func TestInterestTrackingStorePreservesCollectionMembership(t *testing.T) { + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := userdb.InitSchema(db); err != nil { + t.Fatal(err) + } + provider := WrapUserStoreProvider(preferenceTransactionTestProvider{store: userdb.NewSQLiteUserStore(db)}, &System{}) + wrapped, err := provider.ForUser(t.Context(), 1) + if err != nil { + t.Fatal(err) + } + storetest.RunManualCollectionsHolding(t, wrapped) +} diff --git a/internal/notifications/collection_mutations.go b/internal/notifications/collection_mutations.go index 67de612775..a9e070dd41 100644 --- a/internal/notifications/collection_mutations.go +++ b/internal/notifications/collection_mutations.go @@ -34,12 +34,12 @@ func (s *interestTrackingStore) CollectionOrderRevision(ctx context.Context) (in } return store.CollectionOrderRevision(ctx) } -func (s *interestTrackingStore) ReorderCollectionsIfRevision(ctx context.Context, profile string, group *string, ids []string, expected int64) error { +func (s *interestTrackingStore) ReorderCollectionsIfRevision(ctx context.Context, profile string, ids []string, expected int64) error { store, err := s.mutationStore() if err != nil { return err } - return store.ReorderCollectionsIfRevision(ctx, profile, group, ids, expected) + return store.ReorderCollectionsIfRevision(ctx, profile, ids, expected) } func (s *interestTrackingStore) UpdateCollectionGroupIfRevision(ctx context.Context, id string, name, slug *string, mode *userstore.GroupSortMode, expected int64) (*userstore.CollectionGroup, error) { store, err := s.mutationStore() diff --git a/internal/notifications/interest_hooks.go b/internal/notifications/interest_hooks.go index 01e1e05ec0..3c083039de 100644 --- a/internal/notifications/interest_hooks.go +++ b/internal/notifications/interest_hooks.go @@ -64,6 +64,16 @@ func (s *interestTrackingStore) ListAllSectionOverrides(ctx context.Context) ([] return enumerator.ListAllSectionOverrides(ctx) } +// ManualCollectionsHolding preserves Add to collection's membership read; +// both backing stores implement it. +func (s *interestTrackingStore) ManualCollectionsHolding(ctx context.Context, creatorProfileID, mediaItemID string) ([]string, error) { + reader, ok := s.UserStore.(userstore.CollectionMembershipReader) + if !ok { + return nil, errors.New("collection membership reads are not supported") + } + return reader.ManualCollectionsHolding(ctx, creatorProfileID, mediaItemID) +} + func (p *interestTrackingProvider) ForUser(ctx context.Context, userID int) (userstore.UserStore, error) { store, err := p.inner.ForUser(ctx, userID) if err != nil || store == nil { diff --git a/internal/policy/viewer_resolver.go b/internal/policy/viewer_resolver.go index 93e4b74bd1..e64f2d9550 100644 --- a/internal/policy/viewer_resolver.go +++ b/internal/policy/viewer_resolver.go @@ -99,6 +99,10 @@ func (r *ViewerResolver) ResolveFacts(ctx context.Context, input access.ResolveI } } + disabledPreference := preferences.DisabledLibraryIDs + if input.ContentAccessOnly { + disabledPreference = nil + } policyInput := ScopeInput{ SchemaVersion: 1, UserID: user.ID, @@ -108,7 +112,7 @@ func (r *ViewerResolver) ResolveFacts(ctx context.Context, input access.ResolveI AccountRestricted: effective.LibraryIDs != nil, AccountMaxQuality: effective.MaxPlaybackQuality, AccessPolicyRevision: user.AccessPolicyRevision, - DisabledLibraryIDs: preferences.DisabledLibraryIDs, + DisabledLibraryIDs: disabledPreference, ProfileVerified: profileVerified, RequestTime: time.Now().UTC().Format(time.RFC3339), // ResolveInput cannot distinguish API keys from compat callers that diff --git a/internal/policy/viewer_resolver_test.go b/internal/policy/viewer_resolver_test.go index 63e3e5217b..1ae0349e70 100644 --- a/internal/policy/viewer_resolver_test.go +++ b/internal/policy/viewer_resolver_test.go @@ -727,3 +727,45 @@ func assertZeroScope(t *testing.T, scope access.Scope) { } func ptr[T any](value T) *T { return &value } + +// A content-only resolve hands the PDP no hidden libraries, so neither +// resolver lets the profile's browsing preference shrink its access. +func TestViewerResolverContentAccessOnlyIgnoresHiddenLibraries(t *testing.T) { + ctx := context.Background() + pdp := newViewerResolverTestPDP(t, ctx) + cases := []struct { + name string + libraries []int + wantAllowed []int + }{ + {"unrestricted account", nil, nil}, + {"restricted account", []int{1, 2, 3}, []int{1, 2, 3}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + users := viewerResolverUserRepo{user: &models.User{ID: 1, LibraryIDs: tc.libraries, AccessPolicyRevision: 5}} + stores := viewerResolverStoreProvider{store: viewerResolverTestStore{ + profile: &userstore.Profile{ID: "prof-1", MaxContentRating: "PG", PINHash: "locked"}, + settingValues: []userstore.SettingValue{hiddenLibrariesRow("prof-1", `[2,3]`)}, + }} + input := access.ResolveInput{UserID: 1, ProfileID: "prof-1", SkipPINVerification: true, ContentAccessOnly: true} + policyScope, err := NewViewerResolver(users, stores, nil, pdp).Resolve(ctx, input) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + legacyScope, err := access.NewResolver(users, stores, nil).Resolve(ctx, input) + if err != nil { + t.Fatalf("legacy Resolve() error: %v", err) + } + if !reflect.DeepEqual(policyScope, legacyScope) { + t.Fatalf("scope mismatch\npolicy: %#v\nlegacy: %#v", policyScope, legacyScope) + } + if !reflect.DeepEqual(policyScope.AllowedLibraryIDs, tc.wantAllowed) || policyScope.DisabledLibraryIDs != nil { + t.Fatalf("libraries = allowed %#v disabled %#v, want allowed %#v and none disabled", policyScope.AllowedLibraryIDs, policyScope.DisabledLibraryIDs, tc.wantAllowed) + } + if policyScope.MaxContentRating != "PG" { + t.Fatalf("MaxContentRating = %q, want PG", policyScope.MaxContentRating) + } + }) + } +} diff --git a/internal/sections/collection_rail_db_test.go b/internal/sections/collection_rail_db_test.go new file mode 100644 index 0000000000..a2d1b4f083 --- /dev/null +++ b/internal/sections/collection_rail_db_test.go @@ -0,0 +1,242 @@ +package sections + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/jackc/pgx/v5/pgxpool" +) + +// TestLibraryCollectionRailsMatchTheCollectionPageDB checks that a Home or +// library row showing a server collection lists the same titles, in the same +// order, as the collection's own page, for smart collections and for a manual +// collection that carries a query_definition. A cached row costs one statement, +// the revision read that keys it. +func TestLibraryCollectionRailsMatchTheCollectionPageDB(t *testing.T) { + dsn := os.Getenv("SILO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("SILO_TEST_DATABASE_URL is not set") + } + resetResolvedListCacheForTest() + t.Cleanup(resetResolvedListCacheForTest) + ctx := t.Context() + config, err := pgxpool.ParseConfig(dsn) + if err != nil { + t.Fatalf("parse test database url: %v", err) + } + tracer := &nextUpStatementTracer{} + config.ConnConfig.Tracer = tracer + pool, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + t.Fatalf("connect test database: %v", err) + } + t.Cleanup(pool.Close) + exec := func(sql string, args ...any) { + t.Helper() + if _, err := pool.Exec(ctx, sql, args...); err != nil { + t.Fatal(err) + } + } + + prefix := fmt.Sprintf("smart-rail-%d", time.Now().UnixNano()) + var shown, other int + if err := pool.QueryRow(ctx, `INSERT INTO media_folders(type,name,enabled) VALUES('movies',$1,true) RETURNING id`, prefix+"-shown").Scan(&shown); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `INSERT INTO media_folders(type,name,enabled) VALUES('movies',$1,true) RETURNING id`, prefix+"-other").Scan(&other); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM media_items WHERE content_id LIKE $1`, prefix+"%") + _, _ = pool.Exec(context.Background(), `DELETE FROM media_folders WHERE id = ANY($1)`, []int{shown, other}) + }) + movie := func(name string, year, library int) string { + id := prefix + "-" + name + exec(`INSERT INTO media_items(content_id,type,title,status,genres,year) VALUES($1,'movie',$2,'released','{}',$3)`, id, name, year) + exec(`INSERT INTO media_item_libraries(content_id,media_folder_id) VALUES($1,$2)`, id, library) + return id + } + echo := movie("Echo", 2024, shown) + alpha := movie("Alpha", 2023, shown) + delta := movie("Delta", 2022, shown) + bravo := movie("Bravo", 2010, shown) + aardvark := movie("Aardvark", 2025, other) + + repo := catalog.NewLibraryCollectionRepository(pool) + create := func(input catalog.CreateLibraryCollectionInput) *models.LibraryCollection { + t.Helper() + input.Slug = prefix + "-" + input.Title + collection, err := repo.Create(ctx, input) + if err != nil { + t.Fatalf("create collection %q: %v", input.Title, err) + } + t.Cleanup(func() { + _ = repo.Delete(context.Background(), collection.ID) + _, _ = pool.Exec(context.Background(), `DELETE FROM library_collection_revisions WHERE collection_id = $1`, collection.ID) + }) + return collection + } + + fetcher := NewFetcher(pool) + fetcher.CollectionRepo = repo + resolver := catalog.NewCatalogResolver(catalog.NewBrowseRepository(pool), catalog.NewItemRepository(pool)) + rail := func(collectionID string, libraryID *int, access catalog.AccessFilter) ([]string, int) { + t.Helper() + section := ResolvedSection{ + ID: prefix + "-section", + SectionType: SectionCollection, + Title: "Row", + ItemLimit: 2, + Config: json.RawMessage(fmt.Sprintf(`{"library_collection_id":%q}`, collectionID)), + } + got, err := fetcher.FetchOne(ctx, section, libraryID, nil, 0, "", access) + if err != nil { + t.Fatalf("fetch rail: %v", err) + } + return railContentIDs(got.Items), got.TotalCount + } + collectionPage := func(collectionID string, access catalog.AccessFilter) []string { + t.Helper() + page, err := resolver.Resolve(ctx, catalog.CatalogRequest{ + Source: catalog.CatalogSourceLibraryCollection, + CollectionID: collectionID, + CursorPaging: true, + UseSourceOrder: true, + Limit: 10, + }, access) + if err != nil { + t.Fatalf("resolve collection page: %v", err) + } + return railContentIDs(page.Items) + } + viewer := catalog.AccessFilter{AllowedLibraryIDs: []int{shown}} + + t.Run("smart collection", func(t *testing.T) { + // The query keeps the three newest movies; the collection's default + // sort lists them by title. Aardvark is newer but in a library the + // viewer cannot open, and Bravo is too old to be a member. + smart := create(catalog.CreateLibraryCollectionInput{ + LibraryIDs: []int{shown, other}, + Title: "Smart", + CollectionType: "smart", + QueryDefinition: json.RawMessage(`{"media_scope":"movie","sort":{"field":"year","order":"desc"},"limit":3}`), + SortConfig: json.RawMessage(`{"field":"title","order":"asc"}`), + }) + + page := collectionPage(smart.ID, viewer) + assertContentIDs(t, "collection page", page, alpha, delta, echo) + got, total := rail(smart.ID, nil, viewer) + assertContentIDs(t, "rail", got, page[:2]...) + if total != 3 { + t.Fatalf("rail total = %d, want 3 members", total) + } + + // Every request reads the revision, so an edit on any node shows on + // the next read; the cached titles cost nothing more. + tracer.take() + cached, _ := rail(smart.ID, nil, viewer) + assertContentIDs(t, "cached rail", cached, page[:2]...) + if queries := tracer.take(); len(queries) != 1 || !strings.Contains(queries[0], "library_collection_revisions") { + t.Fatalf("cached rail ran %d statements %q, want only the revision read", len(queries), queries) + } + + libraryRow, _ := rail(smart.ID, &other, catalog.AccessFilter{}) + assertContentIDs(t, "library row", libraryRow, aardvark) + + // A rules edit shows on the next read instead of waiting out the + // cached rail. + if err := repo.Update(ctx, catalog.UpdateLibraryCollectionInput{ + ID: smart.ID, + SortConfig: json.RawMessage(`{"field":"title","order":"desc"}`), + }); err != nil { + t.Fatalf("update collection: %v", err) + } + edited, _ := rail(smart.ID, nil, viewer) + assertContentIDs(t, "rail after edit", edited, echo, delta) + }) + + t.Run("smart collection outside the viewer's libraries", func(t *testing.T) { + // The query names only a library the viewer cannot open. Narrowed to + // the viewer's libraries it has no members; it must not fall back to + // every library. + hidden := create(catalog.CreateLibraryCollectionInput{ + LibraryIDs: []int{shown, other}, + Title: "Hidden", + CollectionType: "smart", + QueryDefinition: json.RawMessage(fmt.Sprintf(`{"library_ids":[%d]}`, shown)), + }) + outsider := catalog.AccessFilter{AllowedLibraryIDs: []int{other}} + if got, total := rail(hidden.ID, nil, outsider); len(got) != 0 || total != 0 { + t.Fatalf("rail = %v (total %d), want nothing", got, total) + } + if got := collectionPage(hidden.ID, outsider); len(got) != 0 { + t.Fatalf("collection page = %v, want nothing", got) + } + // Readers that hand the query's own libraries to the executor, such + // as jellycompat BoxSets and collection items, get nothing either. + items, total, err := (&catalog.QueryExecutor{Pool: pool}).Preview(ctx, catalog.QueryDefinition{LibraryIDs: []int{shown}}, outsider, 10) + if err != nil { + t.Fatalf("preview query: %v", err) + } + if got := railContentIDs(items); len(got) != 0 || total != 0 { + t.Fatalf("query = %v (total %d), want nothing", got, total) + } + }) + + t.Run("smart collection with an unusable query", func(t *testing.T) { + // A legacy per-profile rule cannot apply to a shared row. The row + // lists nothing, like an empty collection, instead of failing every + // request that shows it. + legacy := create(catalog.CreateLibraryCollectionInput{ + LibraryIDs: []int{shown}, + Title: "Legacy", + CollectionType: "smart", + QueryDefinition: json.RawMessage(`{"groups":[{"match":"all","rules":[{"field":"watched","op":"is","value":true}]}]}`), + }) + if got, total := rail(legacy.ID, nil, viewer); len(got) != 0 || total != 0 { + t.Fatalf("rail = %v (total %d), want nothing", got, total) + } + }) + + t.Run("manual collection with a query definition", func(t *testing.T) { + // Only collection_type makes a collection live; a manual collection + // lists its stored items wherever it is shown. + manual := create(catalog.CreateLibraryCollectionInput{ + LibraryIDs: []int{shown}, + Title: "Manual", + CollectionType: "manual", + QueryDefinition: json.RawMessage(`{"media_scope":"movie"}`), + }) + if err := repo.ReplaceItems(ctx, manual.ID, []catalog.LibraryCollectionItemInput{ + {MediaItemID: bravo, Position: 0}, + {MediaItemID: echo, Position: 1}, + }); err != nil { + t.Fatalf("store items: %v", err) + } + assertContentIDs(t, "collection page", collectionPage(manual.ID, viewer), bravo, echo) + got, _ := rail(manual.ID, nil, viewer) + assertContentIDs(t, "rail", got, bravo, echo) + }) +} + +func railContentIDs(items []*models.MediaItem) []string { + ids := make([]string, 0, len(items)) + for _, item := range items { + ids = append(ids, item.ContentID) + } + return ids +} + +func assertContentIDs(t *testing.T, label string, got []string, want ...string) { + t.Helper() + if fmt.Sprint(got) != fmt.Sprint(want) { + t.Fatalf("%s = %v, want %v", label, got, want) + } +} diff --git a/internal/sections/collection_rail_fetch_test.go b/internal/sections/collection_rail_fetch_test.go index 5c3a286dde..110bbfb579 100644 --- a/internal/sections/collection_rail_fetch_test.go +++ b/internal/sections/collection_rail_fetch_test.go @@ -1,6 +1,7 @@ package sections import ( + "slices" "testing" "github.com/Silo-Server/silo-server/internal/catalog" @@ -41,3 +42,64 @@ func TestCollectionRailQueryAccessIntersectsExplicitLibrary(t *testing.T) { t.Fatalf("blocked access = %v, want non-nil empty scope", blocked.AllowedLibraryIDs) } } + +func TestCollectionRailQueryAccessKeepsEmptyScopeDenying(t *testing.T) { + got := collectionRailQueryAccess(catalog.AccessFilter{AllowedLibraryIDs: []int{}}, nil, []int{}) + if got.AllowedLibraryIDs == nil || len(got.AllowedLibraryIDs) != 0 { + t.Fatalf("empty scope = %#v, want non-nil empty scope", got.AllowedLibraryIDs) + } +} + +func TestNarrowLibraryScopeNeverWidensAllowedLibraries(t *testing.T) { + tests := []struct { + name string + scope, allowed []int + want []int + }{ + {name: "unrestricted", scope: nil, allowed: nil, want: nil}, + {name: "scope only", scope: []int{1, 2}, allowed: nil, want: []int{1, 2}}, + {name: "allowed only", scope: nil, allowed: []int{2}, want: []int{2}}, + {name: "overlap", scope: []int{1, 2}, allowed: []int{2, 3}, want: []int{2}}, + {name: "disjoint", scope: []int{1}, allowed: []int{2}, want: []int{}}, + {name: "nothing allowed", scope: []int{1}, allowed: []int{}, want: []int{}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := narrowLibraryScope(tt.scope, tt.allowed) + if (got == nil) != (tt.want == nil) || !slices.Equal(got, tt.want) { + t.Fatalf("narrowLibraryScope(%#v, %#v) = %#v, want %#v", tt.scope, tt.allowed, got, tt.want) + } + }) + } +} + +func TestApplySectionLibraryScopeToQueryRejectsDisjointScope(t *testing.T) { + library := 9 + tests := []struct { + name string + query []int + libraryID *int + libraryIDs []int + want []int + ok bool + }{ + {name: "no scope", query: []int{1}, want: []int{1}, ok: true}, + {name: "scope only", libraryIDs: []int{1, 2}, want: []int{1, 2}, ok: true}, + {name: "overlap", query: []int{1, 2}, libraryIDs: []int{2, 3}, want: []int{2}, ok: true}, + {name: "disjoint", query: []int{1}, libraryIDs: []int{2}, ok: false}, + {name: "empty scope", query: []int{1}, libraryIDs: []int{}, ok: false}, + {name: "empty scope, unscoped query", libraryIDs: []int{}, ok: false}, + {name: "library page", query: []int{1}, libraryID: &library, want: []int{9}, ok: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := applySectionLibraryScopeToQuery(catalog.QueryDefinition{LibraryIDs: tt.query}, tt.libraryID, tt.libraryIDs) + if ok != tt.ok { + t.Fatalf("ok = %t, want %t", ok, tt.ok) + } + if ok && !slices.Equal(got.LibraryIDs, tt.want) { + t.Fatalf("library_ids = %v, want %v", got.LibraryIDs, tt.want) + } + }) + } +} diff --git a/internal/sections/fetcher.go b/internal/sections/fetcher.go index cd1522f020..6fcf99eb9e 100644 --- a/internal/sections/fetcher.go +++ b/internal/sections/fetcher.go @@ -3,9 +3,11 @@ package sections import ( "context" "encoding/json" + "errors" "fmt" "log/slog" "maps" + "slices" "sort" "strconv" "strings" @@ -96,6 +98,10 @@ type Fetcher struct { // it. Nil skips promotion. WatchlistPromoter catalog.WatchlistPromoter + // CollectionOwners limits a row of another profile's shared personal + // collection to its owner's access. Without it such a row fails. + CollectionOwners catalog.PersonalCollectionAccess + candidateCacheMu sync.Mutex candidateCache *editorialCandidateCache candidateGroup singleflight.Group @@ -324,7 +330,11 @@ func (f *Fetcher) FetchOne(ctx context.Context, resolved ResolvedSection, librar // cached entry; the pointed-to *MediaItem must not be mutated in place // (see cloneMediaItems). The per-user overlay still runs fresh in // buildSectionsResponse. - key := resolvedListCacheKey(resolved, libraryID, libraryIDs, filter) + var key string + key, err = f.resolvedListKey(ctx, resolved, libraryID, libraryIDs, filter) + if err != nil { + return SectionWithItems{}, err + } items, total, err = getOrRefresh(ctx, key, f.now(), func(loadCtx context.Context) ([]*models.MediaItem, int, error) { return f.fetchSection(loadCtx, resolved, libraryID, libraryIDs, userID, profileID, filter) }) @@ -1083,6 +1093,17 @@ func (f *Fetcher) FetchAll(ctx context.Context, resolved []ResolvedSection, libr return results } +// LogFetchError records a section that failed to load and is shown empty. A +// row whose server or personal collection was deleted is expected to stay on +// a layout until someone removes it, so it logs at debug instead of error. +func LogFetchError(ctx context.Context, component string, sec ResolvedSection, err error) { + if errors.Is(err, catalog.ErrLibraryCollectionNotFound) || errors.Is(err, userstore.ErrCollectionNotFound) { + slog.DebugContext(ctx, "section collection no longer exists", "component", component, "section_id", sec.ID, "type", sec.SectionType, "error", err) + return + } + slog.ErrorContext(ctx, "fetching section items", "component", component, "section_id", sec.ID, "type", sec.SectionType, "error", err) +} + type sectionFetchRunner func(context.Context, ResolvedSection) (SectionWithItems, error) func fetchAllWithRunner(ctx context.Context, resolved []ResolvedSection, maxConcurrency int, runner sectionFetchRunner) []SectionWithItems { @@ -1104,7 +1125,7 @@ func fetchAllWithRunner(ctx context.Context, resolved []ResolvedSection, maxConc result, err := runner(ctx, sec) if err != nil { - slog.ErrorContext(ctx, "fetching section items", "component", "sections", "section_id", sec.ID, "type", sec.SectionType, "error", err) + LogFetchError(ctx, "sections", sec, err) result = SectionWithItems{ ResolvedSection: sec, Items: []*models.MediaItem{}, @@ -1248,6 +1269,24 @@ func (f *Fetcher) fetchCollection(ctx context.Context, s ResolvedSection, librar return nil, 0, fmt.Errorf("loading library collection: %w", err) } + // Library collection rails are shared across profiles (see + // isCacheableSectionType), so only the creator's user-agnostic default + // sort applies here — a viewer's personal override is honored on the + // collection's own browse page. + queryAccess := collectionRailQueryAccess(filter, libraryID, libraryIDs) + membership, err := catalog.ResolveLibraryCollectionMembership(collection, queryAccess.AllowedLibraryIDs) + if err != nil { + // The stored query no longer parses or validates, e.g. a legacy + // per-profile rule. Like jellycompat, list nothing rather than fail + // every request that shows the row; the collection's page reports it. + slog.DebugContext(ctx, "library collection query is unusable", "component", "sections", + "collection_id", collection.ID, "error", err) + return []*models.MediaItem{}, 0, nil + } + if membership.Live { + return catalog.PreviewLiveLibraryCollection(ctx, f.pool, membership, queryAccess, s.ItemLimit) + } + collectionItems, err := f.CollectionRepo.ListItems(ctx, cfg.LibraryCollectionID) if err != nil { return nil, 0, fmt.Errorf("listing collection items: %w", err) @@ -1256,18 +1295,12 @@ func (f *Fetcher) fetchCollection(ctx context.Context, s ResolvedSection, librar return []*models.MediaItem{}, 0, nil } - // Library collection rails are shared across profiles (see - // isCacheableSectionType), so only the creator's user-agnostic default - // applies here — a viewer's personal override is honored on the collection's - // own browse page. - defaultSort, hasDefaultSort := catalog.ParseCollectionDefaultSort(collection.SortConfig, false) - if hasDefaultSort { + if membership.Sort.Field != "" { contentIDs := make([]string, 0, len(collectionItems)) for _, item := range collectionItems { contentIDs = append(contentIDs, item.MediaItemID) } - queryAccess := collectionRailQueryAccess(filter, libraryID, libraryIDs) - items, total, err := catalog.QueryCollectionItemsBySort(ctx, f.pool, contentIDs, defaultSort, queryAccess, s.ItemLimit, "") + items, total, err := catalog.QueryCollectionItemsBySort(ctx, f.pool, contentIDs, membership.Sort, queryAccess, s.ItemLimit, "") if err != nil { return nil, 0, err } @@ -1338,17 +1371,13 @@ func (f *Fetcher) fetchUserCollection(ctx context.Context, s ResolvedSection, li } // Verify the requesting profile can access this collection. - if collection.ProfileID != profileID && collection.CreatorProfileID != profileID { - canAccess := false - for _, allowed := range collection.AllowedProfileIDs { - if allowed == profileID { - canAccess = true - break - } - } - if !canAccess { - return []*models.MediaItem{}, 0, nil - } + if !collection.VisibleTo(profileID) { + return []*models.MediaItem{}, 0, nil + } + // Another profile's collection shows only what its owner can access too. + filter, err = catalog.PersonalCollectionFilter(ctx, f.CollectionOwners, filter, userID, profileID, collection.CreatorProfileID) + if err != nil { + return nil, 0, err } // Smart / live-query collection: parse the query definition and use the @@ -1402,7 +1431,10 @@ func (f *Fetcher) fetchUserCollection(ctx context.Context, s ResolvedSection, li return items, total, nil } - // Exact collection: fetch stored items. + // Exact collection: fetch stored items. The lookups below prefer the + // section's library scope over filter.AllowedLibraryIDs, so limit that + // scope to the owner-narrowed filter first. + libraryIDs = narrowLibraryScope(libraryIDs, filter.AllowedLibraryIDs) collectionItems, err := store.ListCollectionItems(ctx, collectionID) if err != nil { return nil, 0, fmt.Errorf("listing user collection items: %w", err) @@ -2814,6 +2846,26 @@ func effectiveFetchLibraryIDs(libraryIDs []int, filter catalog.AccessFilter) []i return nil } +// narrowLibraryScope limits a caller's library scope to allowed so the scope +// can never widen access. Nil on either side is unrestricted; when both are +// set the result is their intersection, empty but non-nil when they share no +// library so it still denies everything. +func narrowLibraryScope(scope, allowed []int) []int { + switch { + case allowed == nil: + return scope + case scope == nil: + return allowed + } + narrowed := make([]int, 0, len(scope)) + for _, id := range scope { + if slices.Contains(allowed, id) && !slices.Contains(narrowed, id) { + narrowed = append(narrowed, id) + } + } + return narrowed +} + func applyEpisodeTargetLibraryAccess( filter catalog.AccessFilter, libraryID *int, @@ -2836,11 +2888,8 @@ func collectionRailQueryAccess(filter catalog.AccessFilter, libraryID *int, libr result := filter effectiveLibraryIDs := effectiveFetchLibraryIDs(libraryIDs, filter) if libraryID == nil { - if effectiveLibraryIDs == nil { - result.AllowedLibraryIDs = nil - } else { - result.AllowedLibraryIDs = append([]int(nil), effectiveLibraryIDs...) - } + // Clone keeps an empty scope non-nil, so it still denies everything. + result.AllowedLibraryIDs = slices.Clone(effectiveLibraryIDs) return result } diff --git a/internal/sections/library_collection_references_db_test.go b/internal/sections/library_collection_references_db_test.go new file mode 100644 index 0000000000..3be5537c54 --- /dev/null +++ b/internal/sections/library_collection_references_db_test.go @@ -0,0 +1,163 @@ +package sections + +import ( + "context" + "fmt" + "os" + "slices" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// TestLibraryCollectionReferencesDB checks the rows that show a server +// collection: a Home row, a library page row and the starter-pack hero rows a +// template bundle generates are listed, rows showing other collections are +// not, and the per-collection counts match the list. The Home count leaves out +// turned-off Home rows, which no viewer sees; the total keeps them because +// they still block a delete. +func TestLibraryCollectionReferencesDB(t *testing.T) { + dsn := os.Getenv("SILO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("SILO_TEST_DATABASE_URL is not set") + } + pool, err := pgxpool.New(t.Context(), dsn) + if err != nil { + t.Fatalf("connect test database: %v", err) + } + t.Cleanup(pool.Close) + + // One repeatable-read transaction, rolled back at the end, keeps the Home + // page's row count stable while other packages write rows to the same + // database. + tx, err := pool.BeginTx(t.Context(), pgx.TxOptions{IsoLevel: pgx.RepeatableRead}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = tx.Rollback(context.Background()) }) + ctx := context.WithValue(t.Context(), sectionTransactionKey{}, tx) + exec := func(sql string, args ...any) { + t.Helper() + if _, err := tx.Exec(ctx, sql, args...); err != nil { + t.Fatal(err) + } + } + + prefix := fmt.Sprintf("row-refs-%d", time.Now().UnixNano()) + library := func(name string) int { + t.Helper() + var id int + if err := tx.QueryRow(ctx, `INSERT INTO media_folders(type,name,enabled) VALUES('movies',$1,true) RETURNING id`, prefix+"-"+name).Scan(&id); err != nil { + t.Fatal(err) + } + return id + } + kids, films := library("kids"), library("films") + shown, other, unused := prefix+"-shown", prefix+"-other", prefix+"-unused" + + row := func(id, scope string, libraryID *int, position int, title string, featured, enabled bool, config string) { + t.Helper() + exec(`INSERT INTO page_sections(id,scope,library_id,position,section_type,title,featured,config,enabled) + VALUES($1,$2,$3,$4,'collection',$5,$6,$7::jsonb,$8)`, + prefix+"-"+id, scope, libraryID, position, title, featured, config, enabled) + } + plain := func(collectionID string) string { + return fmt.Sprintf(`{"library_collection_id":%q}`, collectionID) + } + hero := func(collectionID, surface string, libraryID int) string { + return fmt.Sprintf(`{"library_collection_id":%q,"generated_source":"template_bundle_featured","template_bundle":"franchise_collections","template_id":"tmdb_franchise_placeholder","surface":%q,"library_id":%d}`, collectionID, surface, libraryID) + } + row("home", "home", nil, 7, "Shown on Home", false, true, plain(shown)) + row("home-hero", "home", nil, 0, "Starter pack hero", true, true, hero(shown, "home", kids)) + row("home-off", "home", nil, 9, "Off on Home", false, false, plain(shown)) + row("kids-off", "library", &kids, 2, "Turned off", false, false, plain(shown)) + row("kids-filler", "library", &kids, 0, "Other row", false, true, `{}`) + row("kids-hero", "library", &kids, 1, "Kids hero", true, true, hero(shown, "library", kids)) + row("films", "library", &films, 0, "Films row", false, true, plain(shown)) + row("other-home", "home", nil, 3, "Other collection", false, true, plain(other)) + row("other-films", "library", &films, 1, "Other films", false, true, plain(other)) + + var homeRows int + if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM page_sections WHERE scope = 'home'`).Scan(&homeRows); err != nil { + t.Fatal(err) + } + + repo := NewRepository(pool) + refs, err := repo.ListLibraryCollectionReferences(ctx, shown) + if err != nil { + t.Fatal(err) + } + type want struct { + id string + scope string + libraryID *int + position int + title string + featured bool + enabled bool + pageRowCount int + } + // Home rows come first, then library pages by library, each in page order. + first, second := kids, films + if films < kids { + first, second = films, kids + } + byLibrary := map[int][]want{ + kids: { + {prefix + "-kids-hero", "library", &kids, 1, "Kids hero", true, true, 3}, + {prefix + "-kids-off", "library", &kids, 2, "Turned off", false, false, 3}, + }, + films: {{prefix + "-films", "library", &films, 0, "Films row", false, true, 2}}, + } + wanted := slices.Concat([]want{ + {prefix + "-home-hero", "home", nil, 0, "Starter pack hero", true, true, homeRows}, + {prefix + "-home", "home", nil, 7, "Shown on Home", false, true, homeRows}, + {prefix + "-home-off", "home", nil, 9, "Off on Home", false, false, homeRows}, + }, byLibrary[first], byLibrary[second]) + if len(refs) != len(wanted) { + t.Fatalf("got %d references, want %d: %+v", len(refs), len(wanted), refs) + } + for i, w := range wanted { + got := refs[i] + if got.ID != w.id || got.Scope != w.scope || got.Position != w.position || got.Title != w.title || + got.Featured != w.featured || got.Enabled != w.enabled || got.PageRowCount != w.pageRowCount || + got.SectionType != SectionCollection || !sameLibrary(got.LibraryID, w.libraryID) { + t.Errorf("reference %d = %+v (page rows %d), want %+v", i, got.PageSection, got.PageRowCount, w) + } + } + + counts, err := repo.CountLibraryCollectionReferencesByID(ctx, []string{shown, other, unused}) + if err != nil { + t.Fatal(err) + } + wantCounts := map[string]LibraryCollectionReferenceCount{ + shown: {Home: 2, Total: len(refs)}, + other: {Home: 1, Total: 2}, + } + if len(counts) != len(wantCounts) || counts[shown] != wantCounts[shown] || counts[other] != wantCounts[other] { + t.Fatalf("counts = %+v, want %+v (no entry for an unused collection)", counts, wantCounts) + } + single, err := repo.CountLibraryCollectionReferences(ctx, shown, "") + if err != nil { + t.Fatal(err) + } + if single != counts[shown].Total { + t.Fatalf("CountLibraryCollectionReferences = %d, grouped total %d", single, counts[shown].Total) + } + + if none, err := repo.ListLibraryCollectionReferences(ctx, unused); err != nil || none == nil || len(none) != 0 { + t.Fatalf("unused collection references = %v, %v; want an empty list", none, err) + } + if empty, err := repo.CountLibraryCollectionReferencesByID(ctx, nil); err != nil || len(empty) != 0 { + t.Fatalf("counting no collections = %v, %v", empty, err) + } +} + +func sameLibrary(a, b *int) bool { + if a == nil || b == nil { + return a == b + } + return *a == *b +} diff --git a/internal/sections/missing_collection_log_test.go b/internal/sections/missing_collection_log_test.go new file mode 100644 index 0000000000..7da019a892 --- /dev/null +++ b/internal/sections/missing_collection_log_test.go @@ -0,0 +1,218 @@ +package sections + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "os" + "sync" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/userstore" + "github.com/Silo-Server/silo-server/internal/userstore/pgstore" +) + +// sectionLogRecorder captures every log record written while a test runs. +type sectionLogRecorder struct { + mu sync.Mutex + records []slog.Record +} + +func (r *sectionLogRecorder) Enabled(context.Context, slog.Level) bool { return true } +func (r *sectionLogRecorder) Handle(_ context.Context, record slog.Record) error { + r.mu.Lock() + defer r.mu.Unlock() + r.records = append(r.records, record.Clone()) + return nil +} +func (r *sectionLogRecorder) WithAttrs([]slog.Attr) slog.Handler { return r } +func (r *sectionLogRecorder) WithGroup(string) slog.Handler { return r } + +// messages lists the messages logged at exactly level. +func (r *sectionLogRecorder) messages(level slog.Level) []string { + r.mu.Lock() + defer r.mu.Unlock() + var out []string + for _, record := range r.records { + if record.Level == level { + out = append(out, record.Message) + } + } + return out +} + +func (r *sectionLogRecorder) reset() { + r.mu.Lock() + defer r.mu.Unlock() + r.records = nil +} + +// recordSectionLogs routes the default logger to a recorder for the test. Tests +// that use it must not run in parallel. +func recordSectionLogs(t *testing.T) *sectionLogRecorder { + t.Helper() + recorder := §ionLogRecorder{} + previous := slog.Default() + slog.SetDefault(slog.New(recorder)) + t.Cleanup(func() { slog.SetDefault(previous) }) + return recorder +} + +type failingSectionStoreProvider struct{ userstore.UserStoreProvider } + +func (failingSectionStoreProvider) ForUser(context.Context, int) (userstore.UserStore, error) { + return nil, errors.New("user store unavailable") +} + +// An unrelated failure still renders the row empty and still logs an ERROR: +// only a deleted collection is expected. +func TestFetchAllLogsUnrelatedSectionErrorsAtError(t *testing.T) { + logs := recordSectionLogs(t) + fetcher := &Fetcher{StoreProvider: failingSectionStoreProvider{}} + row := ResolvedSection{ID: "row", SectionType: SectionCollection, Config: json.RawMessage(`{"user_collection_id":"c1"}`)} + + got := fetcher.FetchAll(t.Context(), []ResolvedSection{row}, nil, nil, 1, "owner", catalog.AccessFilter{}) + + if len(got) != 1 || got[0].ID != "row" || got[0].Items == nil || len(got[0].Items) != 0 { + t.Fatalf("FetchAll = %+v, want the row with no items", got) + } + if errs := logs.messages(slog.LevelError); len(errs) != 1 || errs[0] != "fetching section items" { + t.Fatalf("ERROR records = %q, want one fetching section items record", errs) + } +} + +// TestFetchAllLogsDeletedCollectionsAtDebugDB pins that a Home or library row +// still naming a deleted server or personal collection loads as an empty row +// without an ERROR record, and that a private collection's row on another +// profile's Home shows nothing (#193 S1). +func TestFetchAllLogsDeletedCollectionsAtDebugDB(t *testing.T) { + dsn := os.Getenv("SILO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("SILO_TEST_DATABASE_URL is not set") + } + resetResolvedListCacheForTest() + t.Cleanup(resetResolvedListCacheForTest) + ctx := t.Context() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("connect test database: %v", err) + } + t.Cleanup(pool.Close) + + prefix := fmt.Sprintf("missing-collection-%d", time.Now().UnixNano()) + var account, library int + if err := pool.QueryRow(ctx, `INSERT INTO users(username,role) VALUES($1,'user') RETURNING id`, prefix).Scan(&account); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `INSERT INTO media_folders(type,name,enabled) VALUES('movies',$1,true) RETURNING id`, prefix).Scan(&library); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + cleanup := context.Background() + _, _ = pool.Exec(cleanup, `DELETE FROM media_items WHERE content_id LIKE $1`, prefix+"%") + _, _ = pool.Exec(cleanup, `DELETE FROM media_folders WHERE id=$1`, library) + _, _ = pool.Exec(cleanup, `DELETE FROM users WHERE id=$1`, account) + _, _ = pool.Exec(cleanup, `DELETE FROM user_collection_revisions WHERE user_id=$1`, account) + _, _ = pool.Exec(cleanup, `DELETE FROM user_collection_order_revisions WHERE user_id=$1`, account) + }) + movie := prefix + "-movie" + if _, err := pool.Exec(ctx, `INSERT INTO media_items(content_id,type,title) VALUES($1,'movie','Movie')`, movie); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO media_item_libraries(content_id,media_folder_id) VALUES($1,$2)`, movie, library); err != nil { + t.Fatal(err) + } + + repo := catalog.NewLibraryCollectionRepository(pool) + server, err := repo.Create(ctx, catalog.CreateLibraryCollectionInput{LibraryIDs: []int{library}, Title: "Deleted", Slug: prefix, CollectionType: "manual"}) + if err != nil { + t.Fatal(err) + } + if err := repo.Delete(ctx, server.ID); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM library_collection_revisions WHERE collection_id=$1`, server.ID) + }) + + provider := pgstore.NewPostgresProvider(pool) + store, err := provider.ForUser(ctx, account) + if err != nil { + t.Fatal(err) + } + for _, id := range []string{"owner", "other"} { + if err := store.CreateProfile(ctx, userstore.Profile{ID: id, Name: id}); err != nil { + t.Fatal(err) + } + } + deleted, err := store.CreateCollection(ctx, userstore.CreateCollectionInput{CreatorProfileID: "owner", Name: "Deleted", CollectionType: "manual"}) + if err != nil { + t.Fatal(err) + } + if err := store.DeleteCollection(ctx, deleted.ID); err != nil { + t.Fatal(err) + } + private, err := store.CreateCollection(ctx, userstore.CreateCollectionInput{CreatorProfileID: "owner", Name: "Private", CollectionType: "manual"}) + if err != nil { + t.Fatal(err) + } + if err := store.AddCollectionItem(ctx, private.ID, movie, 0); err != nil { + t.Fatal(err) + } + + fetcher := NewFetcher(pool) + fetcher.CollectionRepo = repo + fetcher.StoreProvider = provider + row := func(id, config string) ResolvedSection { + return ResolvedSection{ID: id, SectionType: SectionCollection, Title: id, ItemLimit: 20, Config: json.RawMessage(config)} + } + assertEmptyRows := func(t *testing.T, got []SectionWithItems, ids ...string) { + t.Helper() + if len(got) != len(ids) { + t.Fatalf("FetchAll returned %d rows, want %d", len(got), len(ids)) + } + for i, id := range ids { + if got[i].ID != id || len(got[i].Items) != 0 || got[i].TotalCount != 0 { + t.Errorf("row %d = %s with %d items (total %d), want empty %s", i, got[i].ID, len(got[i].Items), got[i].TotalCount, id) + } + } + } + logs := recordSectionLogs(t) + + t.Run("deleted collections log at debug", func(t *testing.T) { + logs.reset() + got := fetcher.FetchAll(ctx, []ResolvedSection{ + row("server", fmt.Sprintf(`{"library_collection_id":%q}`, server.ID)), + row("personal", fmt.Sprintf(`{"user_collection_id":%q}`, deleted.ID)), + }, nil, []int{library}, account, "owner", catalog.AccessFilter{AllowedLibraryIDs: []int{library}}) + assertEmptyRows(t, got, "server", "personal") + if errs := logs.messages(slog.LevelError); len(errs) != 0 { + t.Fatalf("ERROR records = %q, want none", errs) + } + if debug := logs.messages(slog.LevelDebug); len(debug) != 2 { + t.Fatalf("DEBUG records = %q, want one per deleted collection", debug) + } + }) + + t.Run("a private collection's row on another profile shows nothing", func(t *testing.T) { + logs.reset() + privateRow := row("private", fmt.Sprintf(`{"user_collection_id":%q}`, private.ID)) + got := fetcher.FetchAll(ctx, []ResolvedSection{privateRow}, nil, []int{library}, account, "other", catalog.AccessFilter{AllowedLibraryIDs: []int{library}}) + assertEmptyRows(t, got, "private") + if errs := logs.messages(slog.LevelError); len(errs) != 0 { + t.Fatalf("ERROR records = %q, want none", errs) + } + // The owner still sees the title, so the row above is empty because + // of the profile, not the fixture. + owned := fetcher.FetchAll(ctx, []ResolvedSection{privateRow}, nil, []int{library}, account, "owner", catalog.AccessFilter{AllowedLibraryIDs: []int{library}}) + if len(owned) != 1 || len(owned[0].Items) != 1 || owned[0].Items[0].ContentID != movie { + t.Fatalf("owner's private row = %+v, want %s", owned, movie) + } + }) +} diff --git a/internal/sections/recipes/custom.go b/internal/sections/recipes/custom.go index 8746e48d94..315a07441b 100644 --- a/internal/sections/recipes/custom.go +++ b/internal/sections/recipes/custom.go @@ -49,14 +49,16 @@ func (c *customFilterRecipe) Validate(raw json.RawMessage) error { func (c *customFilterRecipe) Definition() RecipeDefinition { return RecipeDefinition{ - Type: c.typ, - Category: CategoryCustom, - AdminOnly: true, // gated by sections.allow_profile_custom_sections at the API layer - Hidden: c.typ == "genre", // legacy alias — keep resolvable, hide from UI + Type: c.typ, + Category: CategoryCustom, + Hidden: c.typ == legacyGenreType, // legacy alias — keep resolvable, hide from UI } } +// legacyGenreType is the old name of custom_filter, still resolvable. +const legacyGenreType = "genre" + func init() { - Register(&customFilterRecipe{typ: "genre", cacheTTL: 5 * time.Minute}) + Register(&customFilterRecipe{typ: legacyGenreType, cacheTTL: 5 * time.Minute}) Register(&customFilterRecipe{typ: "custom_filter", cacheTTL: 5 * time.Minute}) } diff --git a/internal/sections/recipes/types.go b/internal/sections/recipes/types.go index e49720f6d9..8a021f2af5 100644 --- a/internal/sections/recipes/types.go +++ b/internal/sections/recipes/types.go @@ -46,7 +46,9 @@ type RecipeDefinition struct { Presets []GalleryPreset `json:"presets"` AvoidDuplicates bool `json:"avoid_duplicates"` SupportsRotation bool `json:"supports_rotation"` - AdminOnly bool `json:"admin_only"` + // AdminOnly recipes may be added only by an admin; a profile that is not + // an admin keeps and changes the sections of them it already has. + AdminOnly bool `json:"admin_only"` // Hidden recipes are still resolvable (so existing sections with that type // keep working) but the API gallery list omits them. Used to phase out // duplicate type aliases like `genre` (which is just a custom_filter). diff --git a/internal/sections/repo.go b/internal/sections/repo.go index 157cec2baa..662ab37263 100644 --- a/internal/sections/repo.go +++ b/internal/sections/repo.go @@ -296,6 +296,90 @@ func (r *Repository) CountLibraryCollectionReferences(ctx context.Context, colle return count, nil } +// LibraryCollectionReference is an admin Home or library page row that shows +// a library collection. PageRowCount is the number of rows, turned off ones +// included, on the page that holds it. +type LibraryCollectionReference struct { + PageSection + PageRowCount int +} + +// LibraryCollectionReferenceCount counts the admin page rows that show one +// library collection. Home counts the turned-on rows on the Home page, the +// ones viewers see. Total counts every row on Home and library pages, turned +// off ones included, because each of them blocks a delete. +type LibraryCollectionReferenceCount struct { + Home int + Total int +} + +// ListLibraryCollectionReferences lists the admin page rows whose config +// points at the given library collection: Home rows first, then library pages +// by library, each in page order. It reads page_sections only, so rows a +// profile added to its own Home are not listed. +func (r *Repository) ListLibraryCollectionReferences(ctx context.Context, collectionID string) ([]LibraryCollectionReference, error) { + rows, err := r.query(ctx).Query(ctx, ` + SELECT s.id, s.scope, s.library_id, s.position, s.section_type, s.title, s.featured, + s.item_limit, s.config, s.enabled, s.created_at, s.updated_at, + (SELECT COUNT(*) FROM page_sections p + WHERE p.scope = s.scope AND p.library_id IS NOT DISTINCT FROM s.library_id) + FROM page_sections s + WHERE s.config->>'library_collection_id' = $1 + ORDER BY s.scope <> 'home', s.library_id, s.position, s.id`, collectionID) + if err != nil { + return nil, fmt.Errorf("listing library collection section references: %w", err) + } + defer rows.Close() + out := []LibraryCollectionReference{} + for rows.Next() { + var ref LibraryCollectionReference + s := &ref.PageSection + if err := rows.Scan( + &s.ID, &s.Scope, &s.LibraryID, &s.Position, &s.SectionType, &s.Title, + &s.Featured, &s.ItemLimit, &s.Config, &s.Enabled, &s.CreatedAt, &s.UpdatedAt, + &ref.PageRowCount, + ); err != nil { + return nil, fmt.Errorf("scanning library collection section reference: %w", err) + } + out = append(out, ref) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("listing library collection section references: %w", err) + } + return out, nil +} + +// CountLibraryCollectionReferencesByID counts, in one query, the admin page +// rows that show each of the given library collections. A collection no row +// shows has no entry. +func (r *Repository) CountLibraryCollectionReferencesByID(ctx context.Context, collectionIDs []string) (map[string]LibraryCollectionReferenceCount, error) { + out := make(map[string]LibraryCollectionReferenceCount) + if len(collectionIDs) == 0 { + return out, nil + } + rows, err := r.query(ctx).Query(ctx, ` + SELECT config->>'library_collection_id', COUNT(*) FILTER (WHERE scope = 'home' AND enabled), COUNT(*) + FROM page_sections + WHERE config->>'library_collection_id' = ANY($1::text[]) + GROUP BY 1`, collectionIDs) + if err != nil { + return nil, fmt.Errorf("counting library collection section references: %w", err) + } + defer rows.Close() + for rows.Next() { + var id string + var count LibraryCollectionReferenceCount + if err := rows.Scan(&id, &count.Home, &count.Total); err != nil { + return nil, fmt.Errorf("scanning library collection section reference count: %w", err) + } + out[id] = count + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("counting library collection section references: %w", err) + } + return out, nil +} + // Reorder updates positions for multiple sections in a transaction. func (r *Repository) reorder(ctx context.Context, entries []ReorderEntry) error { tx := sectionTransaction(ctx) diff --git a/internal/sections/resolvedlistcache.go b/internal/sections/resolvedlistcache.go index 46499ed614..1cd17224d8 100644 --- a/internal/sections/resolvedlistcache.go +++ b/internal/sections/resolvedlistcache.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "fmt" "log/slog" "strconv" "strings" @@ -435,6 +436,27 @@ func resolvedListCacheKey(resolved ResolvedSection, libraryID *int, libraryIDs [ return b.String() } +// resolvedListKey is resolvedListCacheKey plus, for a library collection row, +// the collection's stored revision. Every write to the collection, its items, +// or its libraries advances that revision in the database, so an edit made on +// any node changes the key on every node and the next read rebuilds the row +// instead of serving it until the entry expires. +func (f *Fetcher) resolvedListKey(ctx context.Context, resolved ResolvedSection, libraryID *int, libraryIDs []int, filter catalog.AccessFilter) (string, error) { + key := resolvedListCacheKey(resolved, libraryID, libraryIDs, filter) + if resolved.SectionType != SectionCollection || f.CollectionRepo == nil { + return key, nil + } + collectionID := strings.TrimSpace(ParseCollectionConfig(resolved.Config).LibraryCollectionID) + if collectionID == "" { + return key, nil + } + revision, err := f.CollectionRepo.CollectionRevision(ctx, collectionID) + if err != nil { + return "", fmt.Errorf("loading library collection revision: %w", err) + } + return key + "|collection_revision=" + strconv.FormatInt(revision, 10), nil +} + func hashSectionConfig(config json.RawMessage) string { if len(config) == 0 { return "none" diff --git a/internal/sections/resolver.go b/internal/sections/resolver.go index 4d9bdf4a42..9d7ed5cf46 100644 --- a/internal/sections/resolver.go +++ b/internal/sections/resolver.go @@ -78,8 +78,9 @@ func Resolve(admin []*PageSection, overrides []ProfileSectionOverride) []Resolve return result } -// ResolveForSettings is like Resolve but includes hidden sections with Hidden=true. -// Used by the settings UI so users can toggle visibility. +// ResolveForSettings is like Resolve but includes hidden sections with Hidden=true +// and keeps each admin section's own title in DefaultTitle. Used by the +// settings UI so users can toggle visibility and undo a rename. func ResolveForSettings(admin []*PageSection, overrides []ProfileSectionOverride) []ResolvedSection { overrideBySection := make(map[string]*ProfileSectionOverride) var userAdded []ProfileSectionOverride @@ -101,13 +102,14 @@ func ResolveForSettings(admin []*PageSection, overrides []ProfileSectionOverride continue } rs := ResolvedSection{ - ID: s.ID, - SectionType: s.SectionType, - Title: s.Title, - Featured: s.Featured, - ItemLimit: s.ItemLimit, - Config: s.Config, - Position: s.Position, + ID: s.ID, + SectionType: s.SectionType, + Title: s.Title, + DefaultTitle: s.Title, + Featured: s.Featured, + ItemLimit: s.ItemLimit, + Config: s.Config, + Position: s.Position, } if hasOverride { diff --git a/internal/sections/resolver_test.go b/internal/sections/resolver_test.go index 1cf3a7a13d..1966ac71c9 100644 --- a/internal/sections/resolver_test.go +++ b/internal/sections/resolver_test.go @@ -2,6 +2,7 @@ package sections import ( "encoding/json" + "strings" "testing" ) @@ -155,6 +156,49 @@ func TestResolveForSettings_IncludesHidden(t *testing.T) { } } +// TestResolveForSettings_KeepsAdminTitleAsDefault: the settings view keeps +// the admin row's own title beside the profile's rename, so the profile can +// see what it renamed and go back to it; a profile-built row has no admin +// title. +func TestResolveForSettings_KeepsAdminTitleAsDefault(t *testing.T) { + admin := []*PageSection{ + {ID: "1", Position: 0, SectionType: SectionRecentlyAdded, Title: "Recently Added", ItemLimit: 20, Config: json.RawMessage(`{}`)}, + {ID: "2", Position: 1, SectionType: SectionFavorites, Title: "Favorites", ItemLimit: 10, Config: json.RawMessage(`{}`)}, + } + pos := 2 + overrides := []ProfileSectionOverride{ + {SectionID: "1", Title: "New this week"}, + {ID: "custom-1", IsUserAdded: true, UserSectionType: SectionHiddenGems, UserTitle: "Hidden gems", Position: &pos}, + } + + result := ResolveForSettings(admin, overrides) + if len(result) != 3 { + t.Fatalf("expected 3 sections, got %d", len(result)) + } + got := map[string][2]string{} + for _, r := range result { + got[r.ID] = [2]string{r.Title, r.DefaultTitle} + } + want := map[string][2]string{ + "1": {"New this week", "Recently Added"}, + "2": {"Favorites", "Favorites"}, + "custom-1": {"Hidden gems", ""}, + } + for id, w := range want { + if got[id] != w { + t.Errorf("section %s (title, default title) = %q, want %q", id, got[id], w) + } + } + // v1 bodies embed ResolvedSection; the field must never reach them. + raw, err := json.Marshal(result[0]) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "Recently Added") { + t.Errorf("ResolvedSection JSON carries the default title: %s", raw) + } +} + func TestResolveForSettings_SkipsRemovedSection(t *testing.T) { admin := []*PageSection{ {ID: "1", Position: 0, SectionType: SectionRecentlyAdded, Title: "Recently Added", ItemLimit: 20, Config: json.RawMessage(`{}`)}, diff --git a/internal/sections/types.go b/internal/sections/types.go index ae5b051301..8ed96c2c82 100644 --- a/internal/sections/types.go +++ b/internal/sections/types.go @@ -145,6 +145,11 @@ type ResolvedSection struct { Customized bool `json:"customized"` Hidden bool `json:"hidden,omitempty"` + // DefaultTitle is the admin section's own title, before the profile's + // title override. Only ResolveForSettings fills it, and it is empty for a + // profile-built section. Not serialized: v1 bodies embed this struct. + DefaultTitle string `json:"-"` + // SuppressNextUp, when true on a continue-watching section, skips the // next-up injection (and the combined series collapse/sort that pairs with // it) so the section returns in-progress resume points only. Callers that diff --git a/internal/sections/user_collection_visibility_db_test.go b/internal/sections/user_collection_visibility_db_test.go new file mode 100644 index 0000000000..b67ad7dd9d --- /dev/null +++ b/internal/sections/user_collection_visibility_db_test.go @@ -0,0 +1,158 @@ +package sections + +import ( + "context" + "fmt" + "os" + "reflect" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/userstore" + "github.com/Silo-Server/silo-server/internal/userstore/pgstore" +) + +type stubCollectionOwners map[string]catalog.AccessFilter + +func (s stubCollectionOwners) OwnerFilter(_ context.Context, _ int, ownerProfileID string) (catalog.AccessFilter, error) { + filter, ok := s[ownerProfileID] + if !ok { + return catalog.AccessFilter{}, fmt.Errorf("unknown owner %q", ownerProfileID) + } + return filter, nil +} + +// A home row configured with a personal collection follows the collection +// visibility rule (#1615): another profile's shared collection shows only +// what both its owner and the viewer can access, and another profile's +// private collection shows nothing. +func TestFetchUserCollectionProfileVisibilityPostgres(t *testing.T) { + dsn := os.Getenv("SILO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("SILO_TEST_DATABASE_URL is not set") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("connect test database: %v", err) + } + t.Cleanup(pool.Close) + + prefix := fmt.Sprintf("home-row-visibility-%d", time.Now().UnixNano()) + libraries := make([]int, 2) + for i := range libraries { + if err := pool.QueryRow(ctx, + `INSERT INTO media_folders(type, name, enabled) VALUES ('movies', $1, true) RETURNING id`, + fmt.Sprintf("%s-%d", prefix, i), + ).Scan(&libraries[i]); err != nil { + t.Fatalf("seed library: %v", err) + } + } + ownerOnly, shared := prefix+"-owner-library", prefix+"-other-library" + for i, contentID := range []string{ownerOnly, shared} { + if _, err := pool.Exec(ctx, + `INSERT INTO media_items(content_id, type, title, status, genres) VALUES ($1, 'movie', $1, 'released', '{}')`, + contentID, + ); err != nil { + t.Fatalf("seed item: %v", err) + } + if _, err := pool.Exec(ctx, + `INSERT INTO media_item_libraries(content_id, media_folder_id) VALUES ($1, $2)`, + contentID, libraries[i], + ); err != nil { + t.Fatalf("seed item library: %v", err) + } + } + var userID int + if err := pool.QueryRow(ctx, + `INSERT INTO users(username, role) VALUES ($1, 'user') RETURNING id`, prefix, + ).Scan(&userID); err != nil { + t.Fatalf("seed user: %v", err) + } + t.Cleanup(func() { + cleanup := context.Background() + _, _ = pool.Exec(cleanup, `DELETE FROM users WHERE id = $1`, userID) + _, _ = pool.Exec(cleanup, `DELETE FROM media_items WHERE content_id LIKE $1`, prefix+"%") + _, _ = pool.Exec(cleanup, `DELETE FROM media_folders WHERE id = ANY($1)`, libraries) + }) + + provider := pgstore.NewPostgresProvider(pool) + store, err := provider.ForUser(ctx, userID) + if err != nil { + t.Fatalf("ForUser: %v", err) + } + const owner, viewer = "20000000-0000-4000-8000-000000000001", "20000000-0000-4000-8000-000000000002" + for _, profile := range []string{owner, viewer} { + if err := store.CreateProfile(ctx, userstore.Profile{ID: profile, Name: profile, IsPrimary: profile == owner}); err != nil { + t.Fatalf("CreateProfile: %v", err) + } + } + createWithItems := func(name string, isShared bool) string { + t.Helper() + collection, err := store.CreateCollection(ctx, userstore.CreateCollectionInput{ + CreatorProfileID: owner, Name: name, IsShared: isShared, + }) + if err != nil { + t.Fatalf("CreateCollection(%s): %v", name, err) + } + for i, contentID := range []string{ownerOnly, shared} { + if err := store.AddCollectionItem(ctx, collection.ID, contentID, i); err != nil { + t.Fatalf("AddCollectionItem: %v", err) + } + } + return collection.ID + } + sharedID := createWithItems("Shared", true) + privateID := createWithItems("Private", false) + + f := NewFetcher(pool) + f.StoreProvider = provider + // The owner can open only the first library; the viewer can open both. + f.CollectionOwners = stubCollectionOwners{owner: {AllowedLibraryIDs: []int{libraries[0]}}} + viewerFilter := catalog.AccessFilter{UserID: userID, ProfileID: viewer, AllowedLibraryIDs: libraries} + row := ResolvedSection{ID: "home-row", SectionType: SectionCollection, ItemLimit: 20} + + tests := []struct { + name string + collectionID string + profileID string + filter catalog.AccessFilter + want []string + }{ + { + name: "owner reads own collection with own access", + collectionID: privateID, + profileID: owner, + filter: catalog.AccessFilter{UserID: userID, ProfileID: owner, AllowedLibraryIDs: libraries}, + want: []string{ownerOnly, shared}, + }, + { + name: "shared collection limited to owner and viewer access", + collectionID: sharedID, + profileID: viewer, + filter: viewerFilter, + want: []string{ownerOnly}, + }, + { + name: "another profile's private collection is empty", + collectionID: privateID, + profileID: viewer, + filter: viewerFilter, + want: []string{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + items, total, err := f.fetchUserCollection(ctx, row, nil, nil, userID, tt.profileID, tt.filter, tt.collectionID) + if err != nil { + t.Fatalf("fetchUserCollection: %v", err) + } + if got := sectionMediaItemIDs(items); !reflect.DeepEqual(got, tt.want) || total != len(tt.want) { + t.Fatalf("items = %v (total %d), want %v", got, total, tt.want) + } + }) + } +} diff --git a/internal/usercollections/collages.go b/internal/usercollections/collages.go new file mode 100644 index 0000000000..14ff7ab75e --- /dev/null +++ b/internal/usercollections/collages.go @@ -0,0 +1,26 @@ +package usercollections + +import ( + "strings" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/userstore" +) + +// CollectionDefinition is the part of a stored collection that decides which +// titles it shows, and so which collage it gets. +func CollectionDefinition(c userstore.Collection) catalog.PersonalCollectionDefinition { + return catalog.PersonalCollectionDefinition{ID: c.ID, CollectionType: c.CollectionType, QueryDefinition: c.QueryDefinition, DisplayQueryDefinition: c.DisplayQueryDefinition} +} + +// RefreshCollage builds, in the background, the collage the viewer described +// by access sees for c, so it is ready before the next read. It does nothing +// when collages is nil, when c has an uploaded or imported poster, or when +// account userID's collections are kept outside Postgres, where collections +// have no collage. +func RefreshCollage(collages *catalog.PersonalCollectionCollages, store userstore.UserStore, userID int, c *userstore.Collection, access catalog.AccessFilter) { + if collages == nil || c == nil || strings.TrimSpace(c.PosterURL) != "" || !userstore.HasCatalogSQLState(store) { + return + } + collages.Refresh(userID, CollectionDefinition(*c), access) +} diff --git a/internal/usercollections/listing.go b/internal/usercollections/listing.go index 625e334b1a..92c76ffa13 100644 --- a/internal/usercollections/listing.go +++ b/internal/usercollections/listing.go @@ -8,9 +8,9 @@ import ( "github.com/jackc/pgx/v5/pgxpool" ) -// ServerVisibleCollection is the per-user view of a personal collection the -// owner has opted into their own library Collections tab. Personal collections -// are private to their owner; this list never crosses user boundaries. +// ServerVisibleCollection is a personal collection opted into the library +// Collections tab, as one profile sees it: its own, or another profile's on +// the same login that is shared. This list never crosses logins. type ServerVisibleCollection struct { ID string `json:"id"` CreatorProfileID string `json:"creator_profile_id"` @@ -24,8 +24,11 @@ type ServerVisibleCollection struct { PosterPath string `json:"-"` PosterURL string `json:"poster_url,omitempty"` PosterThumbhash string `json:"poster_thumbhash,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + // PosterIsCollage reports that PosterPath is the viewer's collage. The + // frozen /api/v1 shape does not carry it. + PosterIsCollage bool `json:"-"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } // serverVisibleListLimit caps how many opt-in collections a single library tab @@ -33,12 +36,14 @@ type ServerVisibleCollection struct { // the response. const serverVisibleListLimit = 500 -// ListServerVisibleByLibrary returns the current user's personal collections -// that have opted into their library Collections tab and whose library scope -// matches the requested library. Imported exact collections read library_ids -// from source_config, while legacy rows can fall back to query_definition. A +// ListServerVisibleByLibrary returns the personal collections profileID may +// see (its own, and other profiles' shared collections on the login) that are +// opted into the library Collections tab and whose library scope matches the +// requested library. Imported exact collections read library_ids from +// source_config, while legacy rows can fall back to query_definition. A // collection with no library_ids is treated as library-agnostic and therefore -// visible in every library tab. Other users' collections are never returned. +// visible in every library tab. Other logins' collections and Audiobookshelf +// rows are never returned. func ListServerVisibleByLibrary(ctx context.Context, pool *pgxpool.Pool, userID int, profileID string, libraryID int) ([]ServerVisibleCollection, error) { rows, err := pool.Query(ctx, `WITH visible_collections AS ( @@ -51,13 +56,9 @@ func ListServerVisibleByLibrary(ctx context.Context, pool *pgxpool.Pool, userID END AS scope_config FROM user_personal_collections upc WHERE upc.user_id = $1 + AND upc.native AND upc.include_in_server_collections = TRUE - AND EXISTS ( - SELECT 1 FROM user_personal_collection_profiles vp - WHERE vp.user_id = upc.user_id - AND vp.collection_id = upc.id - AND vp.profile_id = $2 - ) + AND (upc.creator_profile_id = $2 OR upc.is_shared) ) SELECT id, creator_profile_id, name, description, collection_type, item_count, query_definition, display_query_definition, poster_url, poster_thumbhash, created_at, updated_at diff --git a/internal/usercollections/owner_access.go b/internal/usercollections/owner_access.go new file mode 100644 index 0000000000..b5739886a5 --- /dev/null +++ b/internal/usercollections/owner_access.go @@ -0,0 +1,53 @@ +package usercollections + +import ( + "context" + "errors" + "fmt" + + "github.com/Silo-Server/silo-server/internal/access" + "github.com/Silo-Server/silo-server/internal/catalog" +) + +// ScopeResolver resolves a profile's access scope. Both the policy-backed +// viewer resolver and the legacy access.Resolver satisfy it. +type ScopeResolver interface { + Resolve(ctx context.Context, input access.ResolveInput) (access.Scope, error) +} + +// OwnerAccess resolves the content access of a personal collection's owner +// (catalog.PersonalCollectionAccess), so another profile on the login sees +// only the collection's titles the owner can access too. Each call resolves +// afresh; nothing is cached across requests. +type OwnerAccess struct { + resolver ScopeResolver +} + +var _ catalog.PersonalCollectionAccess = (*OwnerAccess)(nil) + +// NewOwnerAccess builds owner access over the resolver the request gates use. +func NewOwnerAccess(resolver ScopeResolver) *OwnerAccess { + return &OwnerAccess{resolver: resolver} +} + +// OwnerFilter answers the owner profile's allowed libraries and maturity +// limits. The owner is not the one asking, so its PIN does not apply, and its +// hidden libraries are left out: they are a browsing preference, not access. +func (o *OwnerAccess) OwnerFilter(ctx context.Context, userID int, ownerProfileID string) (catalog.AccessFilter, error) { + if o == nil || o.resolver == nil { + return catalog.AccessFilter{}, errors.New("personal collection owner access is not configured") + } + scope, err := o.resolver.Resolve(ctx, access.ResolveInput{ + UserID: userID, + ProfileID: ownerProfileID, + SkipPINVerification: true, + ContentAccessOnly: true, + }) + if err != nil { + return catalog.AccessFilter{}, fmt.Errorf("resolving collection owner %s: %w", ownerProfileID, err) + } + return catalog.AccessFilter{ + AllowedLibraryIDs: scope.AllowedLibraryIDs, + MaturityLimits: scope.MaturityLimits, + }, nil +} diff --git a/internal/usercollections/owner_access_test.go b/internal/usercollections/owner_access_test.go new file mode 100644 index 0000000000..ae227a61c2 --- /dev/null +++ b/internal/usercollections/owner_access_test.go @@ -0,0 +1,78 @@ +package usercollections + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/Silo-Server/silo-server/internal/access" + "github.com/Silo-Server/silo-server/internal/catalog" +) + +type recordingScopeResolver struct { + inputs []access.ResolveInput + scope access.Scope + err error +} + +func (r *recordingScopeResolver) Resolve(_ context.Context, input access.ResolveInput) (access.Scope, error) { + r.inputs = append(r.inputs, input) + return r.scope, r.err +} + +func TestOwnerAccessResolvesTheOwnersContentAccess(t *testing.T) { + resolver := &recordingScopeResolver{scope: access.Scope{ + UserID: 7, + ProfileID: "owner", + AllowedLibraryIDs: []int{1, 2}, + DisabledLibraryIDs: []int{9}, + MaturityLimits: access.MaturityLimits{MaxContentRating: "PG", MaxAdvisoryAge: 10, RequireAdvisoryAge: true}, + MaxPlaybackQuality: "480p", + }} + got, err := NewOwnerAccess(resolver).OwnerFilter(t.Context(), 7, "owner") + if err != nil { + t.Fatal(err) + } + want := catalog.AccessFilter{ + AllowedLibraryIDs: []int{1, 2}, + MaturityLimits: access.MaturityLimits{MaxContentRating: "PG", MaxAdvisoryAge: 10, RequireAdvisoryAge: true}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("OwnerFilter = %+v, want only the owner's libraries and maturity limits %+v", got, want) + } + // The owner is not the requester: no PIN applies, and its hidden + // libraries are a browsing preference rather than access. + wantInput := access.ResolveInput{UserID: 7, ProfileID: "owner", SkipPINVerification: true, ContentAccessOnly: true} + if !reflect.DeepEqual(resolver.inputs, []access.ResolveInput{wantInput}) { + t.Fatalf("resolve inputs = %+v, want %+v", resolver.inputs, wantInput) + } +} + +func TestOwnerAccessResolvesOnEveryCall(t *testing.T) { + resolver := &recordingScopeResolver{scope: access.Scope{AllowedLibraryIDs: []int{1}}} + owners := NewOwnerAccess(resolver) + for range 2 { + if _, err := owners.OwnerFilter(t.Context(), 7, "owner"); err != nil { + t.Fatal(err) + } + } + // No cross-request cache: an owner's access change must apply on the + // next read on every node. + if len(resolver.inputs) != 2 { + t.Fatalf("resolved %d times, want 2", len(resolver.inputs)) + } +} + +func TestOwnerAccessFailsClosed(t *testing.T) { + if _, err := NewOwnerAccess(&recordingScopeResolver{err: access.ErrProfileNotFound}).OwnerFilter(t.Context(), 7, "gone"); !errors.Is(err, access.ErrProfileNotFound) { + t.Fatalf("err = %v, want the resolver's error", err) + } + if _, err := NewOwnerAccess(nil).OwnerFilter(t.Context(), 7, "owner"); err == nil { + t.Fatal("an owner access without a resolver answered a filter") + } + var nilOwners *OwnerAccess + if _, err := nilOwners.OwnerFilter(t.Context(), 7, "owner"); err == nil { + t.Fatal("a nil owner access answered a filter") + } +} diff --git a/internal/usercollections/scheduler.go b/internal/usercollections/scheduler.go index 461fa51768..d45238f856 100644 --- a/internal/usercollections/scheduler.go +++ b/internal/usercollections/scheduler.go @@ -114,6 +114,20 @@ func (s *Scheduler) syncOne(ctx context.Context, dc dueCollection, mu *sync.Mute } defer s.inFlight.Delete(dc.CollectionID) + if claimed, err := s.claim(ctx, dc); !claimed { + if err != nil { + s.logger.ErrorContext(ctx, "user collection sync scheduler: failed to claim a due collection", + "user_id", dc.UserID, + "collection_id", dc.CollectionID, + "error", err, + ) + } + mu.Lock() + result.Skipped++ + mu.Unlock() + return + } + startedAt := time.Now() syncCtx, cancel := context.WithTimeout(ctx, collectionutil.SyncTimeout) _, err := s.service.SyncCollection(syncCtx, dc.UserID, dc.CollectionID) @@ -130,7 +144,6 @@ func (s *Scheduler) syncOne(ctx context.Context, dc dueCollection, mu *sync.Mute "duration", dur, "error", err, ) - s.advanceAfterFailure(ctx, dc, time.Now()) return } result.Synced++ @@ -141,20 +154,27 @@ func (s *Scheduler) syncOne(ctx context.Context, dc dueCollection, mu *sync.Mute ) } -// advanceAfterFailure pushes next_sync_at forward by the user-sync minimum -// interval so a broken source does not thrash the scheduler. -func (s *Scheduler) advanceAfterFailure(ctx context.Context, dc dueCollection, after time.Time) { - next := after.Add(time.Duration(MinSyncIntervalHours) * time.Hour) - if _, err := s.pool.Exec(ctx, - `UPDATE user_personal_collections SET next_sync_at = $1 WHERE user_id = $2 AND id = $3`, - next, dc.UserID, dc.CollectionID, - ); err != nil { - s.logger.ErrorContext(ctx, "user collection sync scheduler: failed to advance next_sync_at after failure", - "user_id", dc.UserID, - "collection_id", dc.CollectionID, - "error", err, - ) +// claim takes a due collection for this node before its sync runs. Every +// node runs the scheduler, so two can list the same collection; the claim +// moves next_sync_at past the user-sync minimum interval while it is still +// due, and only the node whose UPDATE does that runs the sync. The moved +// next_sync_at is also the retry time when the sync fails, and the sync +// starts from it, so its own next run replaces it on success and a schedule +// edited while the sync ran keeps what the edit wrote. "Still due" and the +// retry time use the database clock, as listDue does. +func (s *Scheduler) claim(ctx context.Context, dc dueCollection) (bool, error) { + tag, err := s.pool.Exec(ctx, + `UPDATE user_personal_collections + SET next_sync_at = NOW() + make_interval(hours => $1) + WHERE user_id = $2 AND id = $3 + AND sync_schedule IS NOT NULL + AND next_sync_at <= NOW()`, + MinSyncIntervalHours, dc.UserID, dc.CollectionID, + ) + if err != nil { + return false, err } + return tag.RowsAffected() == 1, nil } func (s *Scheduler) IsInFlight(collectionID string) bool { diff --git a/internal/usercollections/scheduler_db_test.go b/internal/usercollections/scheduler_db_test.go new file mode 100644 index 0000000000..b597bf8d58 --- /dev/null +++ b/internal/usercollections/scheduler_db_test.go @@ -0,0 +1,163 @@ +package usercollections + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "sync" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/catalog" + + "github.com/Silo-Server/silo-server/internal/userstore" + "github.com/Silo-Server/silo-server/internal/userstore/pgstore" +) + +// failingOwners fails every sync at the owner-access step, after running +// during, which stands in for whatever happens while the sync runs. +type failingOwners struct{ during func() } + +func (o failingOwners) OwnerFilter(context.Context, int, string) (catalog.AccessFilter, error) { + if o.during != nil { + o.during() + } + return catalog.AccessFilter{}, errors.New("owner access unavailable") +} + +// TestSchedulerFailedSyncRetryAndEditsDB pins a scheduled sync's claim on a +// due collection: one node runs it, a failed sync retries after the minimum +// interval, and a schedule edited while the sync ran, on any node, keeps +// what the edit wrote. +func TestSchedulerFailedSyncRetryAndEditsDB(t *testing.T) { + dsn := os.Getenv("SILO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("SILO_TEST_DATABASE_URL is not set") + } + ctx := t.Context() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + var account int + if err := pool.QueryRow(ctx, `INSERT INTO users(username,role) VALUES($1,'user') RETURNING id`, fmt.Sprintf("scheduler-failure-%d", time.Now().UnixNano())).Scan(&account); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.WithoutCancel(ctx), `DELETE FROM users WHERE id=$1`, account) + _, _ = pool.Exec(context.WithoutCancel(ctx), `DELETE FROM user_collection_revisions WHERE user_id=$1`, account) + }) + provider := pgstore.NewPostgresProvider(pool) + store, err := provider.ForUser(ctx, account) + if err != nil { + t.Fatal(err) + } + if err := store.CreateProfile(ctx, userstore.Profile{ID: "owner", Name: "owner"}); err != nil { + t.Fatal(err) + } + logger := slog.New(slog.DiscardHandler) + // node builds one cluster node's scheduler whose syncs fail after + // running during. + node := func(during func()) *Scheduler { + return NewScheduler(pool, NewService(provider, nil, nil, failingOwners{during: during}, nil, logger), logger) + } + daily, weekly := AllowedSyncSchedules["daily"], AllowedSyncSchedules["weekly"] + due := time.Now().Add(-time.Minute).UTC().Truncate(time.Microsecond) + interval := time.Duration(MinSyncIntervalHours) * time.Hour + + createDue := func(t *testing.T, schedule string) dueCollection { + t.Helper() + c, err := store.CreateCollection(ctx, userstore.CreateCollectionInput{ + CreatorProfileID: "owner", Name: "Synced", CollectionType: "mdblist", QueryDefinition: "{}", + SourceConfig: `{"mode":"mdblist_json","url":"https://mdblist.com/lists/user/list"}`, + SyncSchedule: &schedule, NextSyncAt: &due, + }) + if err != nil { + t.Fatal(err) + } + return dueCollection{UserID: account, CollectionID: c.ID} + } + run := func(t *testing.T, s *Scheduler, dc dueCollection) SchedulerResult { + t.Helper() + var ( + mu sync.Mutex + result SchedulerResult + ) + s.syncOne(ctx, dc, &mu, &result) + return result + } + stored := func(t *testing.T, dc dueCollection) *userstore.Collection { + t.Helper() + got, err := store.GetCollection(ctx, dc.CollectionID) + if err != nil { + t.Fatal(err) + } + return got + } + dbNow := func(t *testing.T) time.Time { + t.Helper() + var now time.Time + if err := pool.QueryRow(ctx, `SELECT NOW()`).Scan(&now); err != nil { + t.Fatal(err) + } + return now + } + + t.Run("a failed sync retries after the minimum interval on the database clock", func(t *testing.T) { + dc := createDue(t, daily) + before := dbNow(t) + if got := run(t, node(nil), dc); got.Failed != 1 { + t.Fatalf("result = %+v, want one failed sync", got) + } + after := dbNow(t) + got := stored(t, dc) + if got.NextSyncAt == nil || got.NextSyncAt.Before(before.Add(interval)) || got.NextSyncAt.After(after.Add(interval)) { + t.Fatalf("next_sync_at = %v, want between %v and %v", got.NextSyncAt, before.Add(interval), after.Add(interval)) + } + }) + t.Run("a collection another node already took is skipped", func(t *testing.T) { + // Both nodes listed the collection as due. The first node's sync + // fails; the second must not run it again, or its later success + // would find the first node's retry time and keep it. + dc := createDue(t, weekly) + nodeA, nodeB := node(nil), node(nil) + if got := run(t, nodeA, dc); got.Failed != 1 { + t.Fatalf("node A result = %+v, want one failed sync", got) + } + if got := run(t, nodeB, dc); got.Skipped != 1 || got.Failed != 0 || got.Synced != 0 { + t.Fatalf("node B result = %+v, want the collection skipped", got) + } + }) + t.Run("a schedule turned off during the sync stays off", func(t *testing.T) { + var dc dueCollection + s := node(func() { + if err := store.UpdateCollection(ctx, userstore.UpdateCollectionInput{ID: dc.CollectionID, RequestProfileID: "owner", ClearSyncSchedule: true, ClearNextSyncAt: true}); err != nil { + t.Error(err) + } + }) + dc = createDue(t, daily) + run(t, s, dc) + if got := stored(t, dc); got.SyncSchedule != nil || got.NextSyncAt != nil { + t.Fatalf("schedule %v, next_sync_at %v; want both null", got.SyncSchedule, got.NextSyncAt) + } + }) + t.Run("a schedule changed during the sync keeps its own next run", func(t *testing.T) { + weeklyNext := time.Now().Add(6 * 24 * time.Hour).UTC().Truncate(time.Microsecond) + var dc dueCollection + s := node(func() { + if err := store.UpdateCollection(ctx, userstore.UpdateCollectionInput{ID: dc.CollectionID, RequestProfileID: "owner", SyncSchedule: &weekly, NextSyncAt: &weeklyNext}); err != nil { + t.Error(err) + } + }) + dc = createDue(t, daily) + run(t, s, dc) + if got := stored(t, dc); got.NextSyncAt == nil || !got.NextSyncAt.Equal(weeklyNext) { + t.Fatalf("next_sync_at = %v, want %v", got.NextSyncAt, weeklyNext) + } + }) +} diff --git a/internal/usercollections/sync.go b/internal/usercollections/sync.go index 20345b45b3..7a4f253628 100644 --- a/internal/usercollections/sync.go +++ b/internal/usercollections/sync.go @@ -2,9 +2,11 @@ package usercollections import ( "context" + "errors" "fmt" "log/slog" "net/http" + "slices" "strings" "time" @@ -14,15 +16,22 @@ import ( "github.com/Silo-Server/silo-server/internal/userstore" ) +// ErrOwnerAccessUnavailable reports that a sync could not resolve what the +// collection's owner profile may access. The sync records it as the +// collection's failed status and leaves the members unchanged; it never fills +// the collection from the whole catalog instead. +var ErrOwnerAccessUnavailable = errors.New("couldn't check which titles this collection's owner can access, so the collection was not updated") + // Service performs sync runs for user-owned imported collections. The result // of each sync is written into user_personal_collection_items via the -// per-user store; downstream catalog reads enforce profile-level access -// filtering, so this service resolves against the entire catalog regardless -// of who owns the collection. +// per-user store. Members are limited to titles the collection's owner +// profile can access, so the item limit fills with titles the owner can see; +// catalog reads still apply each viewer's own access on top. type Service struct { storeProvider userstore.UserStoreProvider items *catalog.ItemRepository libraryItems *catalog.LibraryItemRepository + owners catalog.PersonalCollectionAccess httpClient *http.Client logger *slog.Logger @@ -30,12 +39,19 @@ type Service struct { TMDBLists catalog.TMDBListFetcher TraktCollections catalog.TraktCollectionFetcher TraktTokenResolver catalog.TraktAccessTokenResolver + + // Collages builds the collage of a synced collection without an uploaded + // or imported poster; nil when artwork storage is not configured. + Collages *catalog.PersonalCollectionCollages } +// NewService builds the sync service. owners resolves each collection +// owner's access; without it every sync fails with ErrOwnerAccessUnavailable. func NewService( storeProvider userstore.UserStoreProvider, items *catalog.ItemRepository, libraryItems *catalog.LibraryItemRepository, + owners catalog.PersonalCollectionAccess, httpClient *http.Client, logger *slog.Logger, ) *Service { @@ -47,6 +63,7 @@ func NewService( storeProvider: storeProvider, items: items, libraryItems: libraryItems, + owners: owners, httpClient: httpClient, logger: logger, } @@ -73,32 +90,111 @@ func (s *Service) SyncCollection(ctx context.Context, userID int, collectionID s if err != nil { return nil, err } - result, _, err := s.RunSync(ctx, store, collection) + result, _, err := s.RunSync(ctx, userID, store, collection) return result, err } -// RunSync syncs an already-loaded collection. Handlers that have validated -// ownership pass the collection in to avoid a second GetCollection round -// trip. Returns both the sync result and the post-sync collection state so -// callers can render the updated row without an extra read. -func (s *Service) RunSync(ctx context.Context, store userstore.UserStore, collection *userstore.Collection) (*SyncResult, *userstore.Collection, error) { +// sourceMatcher fetches a collection's source list and returns, for each +// entry in source order, the catalog content_id it names ("" when the +// catalog has no such title). +type sourceMatcher func(ctx context.Context, collection *userstore.Collection, cfg SourceConfig) ([]string, error) + +// RunSync syncs an already-loaded collection of account userID. Handlers +// that have validated ownership pass the collection in to avoid a second +// GetCollection round trip. Returns both the sync result and the post-sync +// collection state so callers can render the updated row without an extra +// read. +func (s *Service) RunSync(ctx context.Context, userID int, store userstore.UserStore, collection *userstore.Collection) (*SyncResult, *userstore.Collection, error) { cfg, err := ParseSourceConfig(collection.SourceConfig) if err != nil { return nil, nil, fmt.Errorf("parsing source_config: %w", err) } - startedAt := time.Now().UTC() + var match sourceMatcher switch cfg.Mode { case SourceModeMDBList: - return s.syncMDBList(ctx, store, collection, cfg, startedAt) + match = s.matchMDBList case SourceModeTMDBPreset: - return s.syncTMDB(ctx, store, collection, cfg, startedAt) + match = s.matchTMDB case SourceModeTMDBList: - return s.syncTMDBList(ctx, store, collection, cfg, startedAt) + match = s.matchTMDBList case SourceModeTraktPreset: - return s.syncTrakt(ctx, store, collection, cfg, startedAt) + match = s.matchTrakt default: return nil, nil, ErrSyncUnsupported } + startedAt := time.Now().UTC() + owner, err := s.ownerFilter(ctx, userID, store, collection) + if err != nil { + return nil, nil, err + } + contentIDs, err := match(ctx, collection, cfg) + if err != nil { + return nil, nil, err + } + members, scanned, unmatched, err := s.selectMembers(ctx, owner, cfg, contentIDs) + if err != nil { + return nil, nil, err + } + result, updated, err := s.applyResult(ctx, store, collection, startedAt, members, len(contentIDs), scanned, unmatched) + if err == nil { + s.refreshCollage(userID, store, updated, owner) + } + return result, updated, err +} + +// refreshCollage builds, in the background, the collage a synced collection +// shows its owner when it has no uploaded or imported poster. A sync has no +// request, so the owner's content access stands in for the owner's own read; +// other viewers' collages are built when they first read the collection. +func (s *Service) refreshCollage(userID int, store userstore.UserStore, c *userstore.Collection, owner catalog.AccessFilter) { + RefreshCollage(s.Collages, store, userID, c, owner) +} + +// ownerFilter resolves the access of the collection's owner profile, which +// limits its members. A scheduled sync has no request, so the owner is the +// only profile whose access can apply. When it cannot be resolved, the sync +// fails: the failure is recorded on the collection, so the owner sees it +// whether the sync ran on a schedule or by hand, and its members stay as +// they were. +func (s *Service) ownerFilter(ctx context.Context, userID int, store userstore.UserStore, collection *userstore.Collection) (catalog.AccessFilter, error) { + var ( + filter catalog.AccessFilter + err error + ) + switch { + case s.owners == nil: + err = errors.New("owner access is not configured") + case collection.CreatorProfileID == "": + err = errors.New("the collection has no owner profile") + default: + filter, err = s.owners.OwnerFilter(ctx, userID, collection.CreatorProfileID) + } + if err == nil { + return filter, nil + } + s.logger.ErrorContext(ctx, "user collection sync: resolving owner access failed", + "user_id", userID, + "collection_id", collection.ID, + "owner_profile_id", collection.CreatorProfileID, + "error", err, + ) + if stateErr := store.UpdateCollectionSyncState(ctx, userstore.UpdateCollectionSyncStateInput{ + ID: collection.ID, + Status: "failed", + Message: ErrOwnerAccessUnavailable.Error(), + ItemCount: collection.ItemCount, + LastSyncAt: time.Now().UTC(), + NextSyncAt: collection.NextSyncAt, + ScheduleAtStart: collection.SyncSchedule, + NextSyncAtAtStart: collection.NextSyncAt, + }); stateErr != nil { + s.logger.ErrorContext(ctx, "user collection sync: recording the failed sync failed", + "user_id", userID, + "collection_id", collection.ID, + "error", stateErr, + ) + } + return catalog.AccessFilter{}, ErrOwnerAccessUnavailable } // ── MDBList ────────────────────────────────────────────────────────────────── @@ -113,10 +209,10 @@ type mdblistEntry struct { ReleaseYear int `json:"release_year"` } -func (s *Service) syncMDBList(ctx context.Context, store userstore.UserStore, collection *userstore.Collection, cfg SourceConfig, startedAt time.Time) (*SyncResult, *userstore.Collection, error) { +func (s *Service) matchMDBList(ctx context.Context, collection *userstore.Collection, cfg SourceConfig) ([]string, error) { urls := collectionutil.MDBListURLCandidates(cfg.URL, collection.SourceURL) if len(urls) == 0 { - return nil, nil, fmt.Errorf("mdblist sync: url is required") + return nil, fmt.Errorf("mdblist sync: url is required") } fetchLimit := collectionutil.SourceFetchLimit(cfg.Limit) @@ -124,7 +220,7 @@ func (s *Service) syncMDBList(ctx context.Context, store userstore.UserStore, co return s.fetchMDBListEntries(ctx, url, fetchLimit) }) if err != nil { - return nil, nil, err + return nil, err } var movieBatch, seriesBatch catalog.ExternalIDBatch @@ -146,15 +242,14 @@ func (s *Service) syncMDBList(ctx context.Context, store userstore.UserStore, co movieLookup, err := s.items.GetByExternalIDs(ctx, movieBatch, "movie") if err != nil { - return nil, nil, err + return nil, err } seriesLookup, err := s.items.GetByExternalIDs(ctx, seriesBatch, "series") if err != nil { - return nil, nil, err + return nil, err } - resolveLimit := collectionResolveLimit(cfg) - matched, unmatched, scanned := resolveMatchedWithLimit(len(entries), resolveLimit, func(i int) string { + return matchEntries(len(entries), func(i int) string { entry := entries[i] itemType := mdbListItemType(entry) lookup := movieLookup @@ -170,13 +265,7 @@ func (s *Service) syncMDBList(ctx context.Context, store userstore.UserStore, co tmdb = fmt.Sprintf("%d", entry.ID) } return resolveCandidate(lookup, itemType, tvdb, tmdb, entry.IMDbID) - }) - matched, droppedByLib, err := s.filterByLibraries(ctx, matched, cfg.LibraryIDs) - if err != nil { - return nil, nil, err - } - matched = limitCollectionItems(matched, cfg.Limit) - return s.applyResult(ctx, store, collection, startedAt, matched, len(entries), scanned, unmatched+droppedByLib) + }), nil } func mdbListItemType(entry mdblistEntry) string { @@ -212,92 +301,88 @@ func resolveCandidate(lookup *catalog.ExternalIDLookup, itemType, tvdbID, tmdbID return "" } -// filterByLibraries drops matched items that are not present in any of the -// supplied libraries. Returns the surviving items (with positions -// recompacted) plus a count of items removed by the filter, which the caller -// rolls into the unmatched count so the user sees an honest match summary. -func (s *Service) filterByLibraries(ctx context.Context, matched []userstore.CollectionItemReplacement, libraryIDs []int) ([]userstore.CollectionItemReplacement, int, error) { - if len(libraryIDs) == 0 || len(matched) == 0 || s.libraryItems == nil { - return matched, 0, nil +// matchEntries resolves each of total source entries to its catalog +// content_id, in source order. +func matchEntries(total int, resolve func(i int) string) []string { + contentIDs := make([]string, total) + for i := range contentIDs { + contentIDs[i] = resolve(i) } - ids := make([]string, len(matched)) - for i, m := range matched { - ids[i] = m.MediaItemID + return contentIDs +} + +// selectMembers picks the collection's members from the matched source +// entries: titles the owner can access and, when the source config names +// libraries, that are in one of them. All matches are checked before the item +// limit applies, so titles the owner can't access never use up the limit. +func (s *Service) selectMembers(ctx context.Context, owner catalog.AccessFilter, cfg SourceConfig, contentIDs []string) ([]userstore.CollectionItemReplacement, int, int, error) { + ids := make([]string, 0, len(contentIDs)) + seen := make(map[string]struct{}, len(contentIDs)) + for _, id := range contentIDs { + if _, dup := seen[id]; id == "" || dup { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) } - membership, err := s.libraryItems.GetItemsInFolders(ctx, ids, libraryIDs) + allowed, err := s.items.EnsureAccessibleIDs(ctx, ids, owner) if err != nil { - return nil, 0, err - } - kept := make([]userstore.CollectionItemReplacement, 0, len(matched)) - for _, m := range matched { - if !membership[m.MediaItemID] { - continue + return nil, 0, 0, fmt.Errorf("checking the owner's access to matched titles: %w", err) + } + if len(cfg.LibraryIDs) > 0 && s.libraryItems != nil { + // A title must sit in a chosen library the owner can also access, not + // pass each check through a different library. + libraries := cfg.LibraryIDs + if owner.AllowedLibraryIDs != nil { + libraries = slices.DeleteFunc(slices.Clone(libraries), func(id int) bool { + return !slices.Contains(owner.AllowedLibraryIDs, id) + }) + } + inLibraries := map[string]bool{} + if len(libraries) > 0 { + inLibraries, err = s.libraryItems.GetItemsInFolders(ctx, ids, libraries) + if err != nil { + return nil, 0, 0, err + } + } + for id := range allowed { + allowed[id] = inLibraries[id] } - m.Position = len(kept) - kept = append(kept, m) } - return kept, len(matched) - len(kept), nil + members, scanned, unmatched := pickMembers(contentIDs, allowed, cfg.Limit) + return members, scanned, unmatched, nil } -// resolveMatched walks `total` entries, calls `resolve` to get the candidate -// content_id for each, and produces deduped, position-numbered replacements -// plus an unmatched count. Shared by all three source backends. -func resolveMatchedWithLimit(total int, limit *int, resolve func(i int) string) ([]userstore.CollectionItemReplacement, int, int) { - capacity := total - if limit != nil && *limit > 0 && *limit < total { +// pickMembers walks the matched source entries in order and keeps each +// allowed title once, until limit members are kept. It returns the members, +// the number of entries scanned, and how many of those were unmatched. An +// entry whose title is not allowed counts as unmatched, exactly like one the +// catalog lacks, so the sync summary never reveals titles outside the +// owner's access. +func pickMembers(contentIDs []string, allowed map[string]bool, limit *int) ([]userstore.CollectionItemReplacement, int, int) { + capacity := len(contentIDs) + if limit != nil && *limit > 0 && *limit < capacity { capacity = *limit } - matched := make([]userstore.CollectionItemReplacement, 0, capacity) + members := make([]userstore.CollectionItemReplacement, 0, capacity) seen := make(map[string]struct{}, capacity) - unmatched := 0 - scanned := 0 - for i := 0; i < total; i++ { - scanned = i + 1 - contentID := resolve(i) - if contentID == "" { + scanned, unmatched := 0, 0 + for _, id := range contentIDs { + scanned++ + if !allowed[id] { unmatched++ continue } - if _, dup := seen[contentID]; dup { + if _, dup := seen[id]; dup { continue } - seen[contentID] = struct{}{} - matched = append(matched, userstore.CollectionItemReplacement{ - MediaItemID: contentID, - Position: len(matched), - }) - if collectionutil.ItemLimitReached(len(matched), limit) { + seen[id] = struct{}{} + members = append(members, userstore.CollectionItemReplacement{MediaItemID: id, Position: len(members)}) + if collectionutil.ItemLimitReached(len(members), limit) { break } } - return matched, unmatched, scanned -} - -// collectionResolveLimit returns the limit to pass into resolveMatchedWithLimit. -// -// When LibraryIDs is set we cannot break early on cfg.Limit during the resolve -// pass: filterByLibraries runs after resolve and may drop most items, so an -// early break would leave us short of cfg.Limit final items. Returning nil -// disables the inline break; limitCollectionItems then truncates to cfg.Limit -// after filtering. This trades a wider GetItemsInFolders IN-array (bounded by -// the source-fetch cap) for a correct result count when the library filter is -// selective. -func collectionResolveLimit(cfg SourceConfig) *int { - if len(cfg.LibraryIDs) > 0 { - return nil - } - return cfg.Limit -} - -func limitCollectionItems(items []userstore.CollectionItemReplacement, limit *int) []userstore.CollectionItemReplacement { - if limit == nil || *limit <= 0 || len(items) <= *limit { - return items - } - items = items[:*limit] - for i := range items { - items[i].Position = i - } - return items + return members, scanned, unmatched } func (s *Service) fetchMDBListEntries(ctx context.Context, url string, maxEntries int) ([]mdblistEntry, error) { @@ -306,9 +391,9 @@ func (s *Service) fetchMDBListEntries(ctx context.Context, url string, maxEntrie // ── TMDB presets ───────────────────────────────────────────────────────────── -func (s *Service) syncTMDB(ctx context.Context, store userstore.UserStore, collection *userstore.Collection, cfg SourceConfig, startedAt time.Time) (*SyncResult, *userstore.Collection, error) { +func (s *Service) matchTMDB(ctx context.Context, _ *userstore.Collection, cfg SourceConfig) ([]string, error) { if s.TMDBCollections == nil { - return nil, nil, fmt.Errorf("TMDB sync requires configured TMDB access") + return nil, fmt.Errorf("TMDB sync requires configured TMDB access") } preset := cfg.Preset mediaType := cfg.MediaType @@ -321,16 +406,16 @@ func (s *Service) syncTMDB(ctx context.Context, store userstore.UserStore, colle if err != nil { // The error text reaches the collection's stored sync message, and a // transport error embeds the request URL, which carries the API key. - return nil, nil, fmt.Errorf("fetching TMDB preset: %w", logredact.SanitizeURLError(err)) + return nil, fmt.Errorf("fetching TMDB preset: %w", logredact.SanitizeURLError(err)) } - return s.completeTMDBSync(ctx, store, collection, cfg, startedAt, results) + return s.matchTMDBEntries(ctx, results) } // ── TMDB lists ─────────────────────────────────────────────────────────────── -func (s *Service) syncTMDBList(ctx context.Context, store userstore.UserStore, collection *userstore.Collection, cfg SourceConfig, startedAt time.Time) (*SyncResult, *userstore.Collection, error) { +func (s *Service) matchTMDBList(ctx context.Context, collection *userstore.Collection, cfg SourceConfig) ([]string, error) { if s.TMDBLists == nil { - return nil, nil, fmt.Errorf("TMDB list sync requires configured TMDB access") + return nil, fmt.Errorf("TMDB list sync requires configured TMDB access") } listURL := cfg.URL if strings.TrimSpace(listURL) == "" { @@ -338,18 +423,18 @@ func (s *Service) syncTMDBList(ctx context.Context, store userstore.UserStore, c } listID, err := collectionutil.ParseTMDBListURL(listURL) if err != nil { - return nil, nil, fmt.Errorf("TMDB list sync: %w", err) + return nil, fmt.Errorf("TMDB list sync: %w", err) } results, err := s.TMDBLists.GetList(ctx, listID, collectionutil.SourceFetchLimit(cfg.Limit)) if err != nil { - return nil, nil, fmt.Errorf("fetching TMDB list %d: %w", listID, logredact.SanitizeURLError(err)) + return nil, fmt.Errorf("fetching TMDB list %d: %w", listID, logredact.SanitizeURLError(err)) } - return s.completeTMDBSync(ctx, store, collection, cfg, startedAt, results) + return s.matchTMDBEntries(ctx, results) } -// completeTMDBSync matches fetched TMDB entries against the catalog in source -// order and stores the result. Shared by the TMDB preset and list sources. -func (s *Service) completeTMDBSync(ctx context.Context, store userstore.UserStore, collection *userstore.Collection, cfg SourceConfig, startedAt time.Time, results []catalog.TMDBCollectionEntry) (*SyncResult, *userstore.Collection, error) { +// matchTMDBEntries matches fetched TMDB entries against the catalog in source +// order. Shared by the TMDB preset and list sources. +func (s *Service) matchTMDBEntries(ctx context.Context, results []catalog.TMDBCollectionEntry) ([]string, error) { // TMDB returns mixed-media-type results (the "trending all" preset and // user lists can emit both movie and tv). Batch by item type so each gets // a single catalog lookup instead of N round-trips through GetByExternalID. @@ -371,15 +456,14 @@ func (s *Service) completeTMDBSync(ctx context.Context, store userstore.UserStor } movieLookup, err := s.items.GetByExternalIDs(ctx, movieBatch, "movie") if err != nil { - return nil, nil, err + return nil, err } seriesLookup, err := s.items.GetByExternalIDs(ctx, seriesBatch, "series") if err != nil { - return nil, nil, err + return nil, err } - resolveLimit := collectionResolveLimit(cfg) - matched, unmatched, scanned := resolveMatchedWithLimit(len(results), resolveLimit, func(i int) string { + return matchEntries(len(results), func(i int) string { entry := results[i] itemType := "movie" lookup := movieLookup @@ -396,20 +480,14 @@ func (s *Service) completeTMDBSync(ctx context.Context, store userstore.UserStor tvdb = fmt.Sprintf("%d", entry.TVDBID) } return resolveCandidate(lookup, itemType, tvdb, tmdb, entry.IMDbID) - }) - matched, droppedByLib, err := s.filterByLibraries(ctx, matched, cfg.LibraryIDs) - if err != nil { - return nil, nil, err - } - matched = limitCollectionItems(matched, cfg.Limit) - return s.applyResult(ctx, store, collection, startedAt, matched, len(results), scanned, unmatched+droppedByLib) + }), nil } // ── Trakt presets ──────────────────────────────────────────────────────────── -func (s *Service) syncTrakt(ctx context.Context, store userstore.UserStore, collection *userstore.Collection, cfg SourceConfig, startedAt time.Time) (*SyncResult, *userstore.Collection, error) { +func (s *Service) matchTrakt(ctx context.Context, collection *userstore.Collection, cfg SourceConfig) ([]string, error) { if s.TraktCollections == nil { - return nil, nil, fmt.Errorf("Trakt sync requires configured Trakt access") + return nil, errors.New("Trakt sync requires configured Trakt access") //nolint:staticcheck // ST1005: user-facing sync status, matches library collection wording } preset := strings.TrimSpace(cfg.Preset) mediaType := strings.TrimSpace(cfg.MediaType) @@ -417,7 +495,7 @@ func (s *Service) syncTrakt(ctx context.Context, store userstore.UserStore, coll mediaType = "movie" } if preset != "trending" && preset != "popular" && preset != "recommended" { - return nil, nil, fmt.Errorf("unsupported Trakt preset: %s", preset) + return nil, fmt.Errorf("unsupported Trakt preset: %s", preset) } accessToken := "" @@ -427,11 +505,11 @@ func (s *Service) syncTrakt(ctx context.Context, store userstore.UserStore, coll profileID = collection.CreatorProfileID } if profileID == "" || s.TraktTokenResolver == nil { - return nil, nil, fmt.Errorf("Trakt recommendations require a profile binding") + return nil, errors.New("Trakt recommendations require a profile binding") //nolint:staticcheck // ST1005: user-facing sync status, matches library collection wording } token, err := s.TraktTokenResolver.ResolveTraktAccessToken(ctx, profileID) if err != nil { - return nil, nil, fmt.Errorf("resolving Trakt access token: %w", err) + return nil, fmt.Errorf("resolving Trakt access token: %w", err) } accessToken = token } @@ -439,7 +517,7 @@ func (s *Service) syncTrakt(ctx context.Context, store userstore.UserStore, coll limit := collectionutil.SourceFetchLimit(cfg.Limit) results, err := s.TraktCollections.GetCollectionPreset(ctx, preset, mediaType, limit, accessToken) if err != nil { - return nil, nil, fmt.Errorf("fetching Trakt preset: %w", err) + return nil, fmt.Errorf("fetching Trakt preset: %w", err) } itemType := "movie" @@ -460,11 +538,10 @@ func (s *Service) syncTrakt(ctx context.Context, store userstore.UserStore, coll } lookup, err := s.items.GetByExternalIDs(ctx, batch, itemType) if err != nil { - return nil, nil, err + return nil, err } - resolveLimit := collectionResolveLimit(cfg) - matched, unmatched, scanned := resolveMatchedWithLimit(len(results), resolveLimit, func(i int) string { + return matchEntries(len(results), func(i int) string { entry := results[i] var tmdb, tvdb string if entry.TMDBID > 0 { @@ -474,13 +551,7 @@ func (s *Service) syncTrakt(ctx context.Context, store userstore.UserStore, coll tvdb = fmt.Sprintf("%d", entry.TVDBID) } return resolveCandidate(lookup, itemType, tvdb, tmdb, entry.IMDbID) - }) - matched, droppedByLib, err := s.filterByLibraries(ctx, matched, cfg.LibraryIDs) - if err != nil { - return nil, nil, err - } - matched = limitCollectionItems(matched, cfg.Limit) - return s.applyResult(ctx, store, collection, startedAt, matched, len(results), scanned, unmatched+droppedByLib) + }), nil } // ── Result application ─────────────────────────────────────────────────────── @@ -515,22 +586,24 @@ func (s *Service) applyResult( } if err := store.UpdateCollectionSyncState(ctx, userstore.UpdateCollectionSyncStateInput{ - ID: collection.ID, - Status: status, - Message: message, - ItemCount: len(matched), - LastSyncAt: completedAt, - NextSyncAt: nextSyncAt, + ID: collection.ID, + Status: status, + Message: message, + ItemCount: len(matched), + LastSyncAt: completedAt, + NextSyncAt: nextSyncAt, + ScheduleAtStart: collection.SyncSchedule, + NextSyncAtAtStart: collection.NextSyncAt, }); err != nil { return nil, nil, err } - updated := *collection - updated.LastSyncAt = &completedAt - updated.LastSyncStatus = status - updated.LastSyncMessage = message - updated.ItemCount = len(matched) - updated.NextSyncAt = nextSyncAt + // Read the row back: a schedule edited while the sync ran, on any node, + // kept its own next run, and the caller renders what was stored. + updated, err := store.GetCollection(ctx, collection.ID) + if err != nil { + return nil, nil, fmt.Errorf("reading the synced collection: %w", err) + } s.logger.InfoContext(ctx, "user collection synced", "collection_id", collection.ID, @@ -549,5 +622,5 @@ func (s *Service) applyResult( ItemsUnmatched: unmatched, StartedAt: startedAt, CompletedAt: completedAt, - }, &updated, nil + }, updated, nil } diff --git a/internal/usercollections/sync_collage_db_test.go b/internal/usercollections/sync_collage_db_test.go new file mode 100644 index 0000000000..f354197c1f --- /dev/null +++ b/internal/usercollections/sync_collage_db_test.go @@ -0,0 +1,138 @@ +package usercollections + +import ( + "context" + "fmt" + "log/slog" + "os" + "slices" + "strconv" + "sync" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/access" + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/userstore" + "github.com/Silo-Server/silo-server/internal/userstore/pgstore" +) + +// pgOwner limits every collection owner to a PG rating ceiling. +type pgOwner struct{} + +func (pgOwner) OwnerFilter(context.Context, int, string) (catalog.AccessFilter, error) { + return catalog.AccessFilter{MaturityLimits: access.MaturityLimits{MaxContentRating: "PG"}}, nil +} + +// syncCollageGenerator records the collages it composes instead of storing them. +type syncCollageGenerator struct { + mu sync.Mutex + composed map[string][]string +} + +func (g *syncCollageGenerator) CollectionCollagePath(collectionID, key string) string { + return fmt.Sprintf("user-collection-images/%s/collage/original.%s.webp", collectionID, key) +} + +func (g *syncCollageGenerator) ComposeCollectionCollage(_ context.Context, collectionID, key string, sources []string) (string, string, error) { + g.mu.Lock() + defer g.mu.Unlock() + if g.composed == nil { + g.composed = map[string][]string{} + } + g.composed[key] = slices.Clone(sources) + return g.CollectionCollagePath(collectionID, key), "th", nil +} + +// TestSyncBuildsTheOwnersCollageDB checks that a finished sync builds the +// collage its owner sees, from the synced titles the owner can access. +func TestSyncBuildsTheOwnersCollageDB(t *testing.T) { + dsn := os.Getenv("SILO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("SILO_TEST_DATABASE_URL is not set") + } + ctx := t.Context() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + + suffix := time.Now().UnixNano() + prefix := fmt.Sprintf("sync-collage-%d", suffix) + var account int + if err := pool.QueryRow(ctx, `INSERT INTO users(username,role) VALUES($1,'user') RETURNING id`, prefix).Scan(&account); err != nil { + t.Fatal(err) + } + var collectionID string + t.Cleanup(func() { + ctx := context.Background() + _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id=$1`, account) + _, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id LIKE $1`, prefix+"%") + _, _ = pool.Exec(ctx, `DELETE FROM user_collection_revisions WHERE user_id=$1`, account) + _, _ = pool.Exec(ctx, `DELETE FROM artwork_revision_gc_candidates WHERE original_path LIKE $1`, "user-collection-images/"+collectionID+"/%") + }) + // The list, in order: an R title, then two PG titles with posters. + tmdbBase := int(suffix % 1_000_000_000 * 10) + var entries staticTMDBList + poster := func(i int) string { return fmt.Sprintf("test/%s-%d/poster/original.webp", prefix, i) } + for i, age := range []int{17, 8, 8} { + tmdb := tmdbBase + i + entries = append(entries, catalog.TMDBCollectionEntry{ID: tmdb, MediaType: "movie"}) + if _, err := pool.Exec(ctx, `INSERT INTO media_items(content_id,type,title,tmdb_id,content_rating_age,poster_path) VALUES($1,'movie',$1,$2,$3,$4)`, + fmt.Sprintf("%s-%d", prefix, i), strconv.Itoa(tmdb), age, poster(i)); err != nil { + t.Fatal(err) + } + } + + provider := pgstore.NewPostgresProvider(pool) + store, err := provider.ForUser(ctx, account) + if err != nil { + t.Fatal(err) + } + if err := store.CreateProfile(ctx, userstore.Profile{ID: "owner", Name: "owner"}); err != nil { + t.Fatal(err) + } + collection, err := store.CreateCollection(ctx, userstore.CreateCollectionInput{ + CreatorProfileID: "owner", Name: "Imported", CollectionType: "tmdb", QueryDefinition: "{}", + SourceConfig: `{"mode":"tmdb_list","url":"https://www.themoviedb.org/list/310"}`, + }) + if err != nil { + t.Fatal(err) + } + collectionID = collection.ID + + gen := &syncCollageGenerator{} + svc := NewService(provider, catalog.NewItemRepository(pool), catalog.NewLibraryItemRepository(pool), pgOwner{}, nil, slog.New(slog.DiscardHandler)) + svc.TMDBLists = entries + svc.Collages = catalog.NewPersonalCollectionCollages(pool, gen) + svc.Collages.RefreshDelay = 0 + if _, err := svc.SyncCollection(ctx, account, collection.ID); err != nil { + t.Fatal(err) + } + + want := []string{poster(1), poster(2)} + key := catalog.CollectionCollageKey(want) + deadline := time.Now().Add(10 * time.Second) + for { + var stored bool + if err := pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM user_personal_collection_poster_variants WHERE user_id=$1 AND collection_id=$2 AND variant_key=$3)`, + account, collection.ID, key).Scan(&stored); err != nil { + t.Fatal(err) + } + if stored { + break + } + if time.Now().After(deadline) { + t.Fatalf("the sync built no collage from %v", want) + } + time.Sleep(20 * time.Millisecond) + } + gen.mu.Lock() + defer gen.mu.Unlock() + if got := gen.composed[key]; !slices.Equal(got, want) { + t.Fatalf("collage composed from %v, want %v", got, want) + } +} diff --git a/internal/usercollections/sync_mdblist_url_test.go b/internal/usercollections/sync_mdblist_url_test.go index 848c1618e6..e162c59ae3 100644 --- a/internal/usercollections/sync_mdblist_url_test.go +++ b/internal/usercollections/sync_mdblist_url_test.go @@ -25,7 +25,7 @@ func TestFetchMDBListEntriesDoesNotDialPrivateHosts(t *testing.T) { t.Parallel() transport := &countingRoundTripper{} - svc := NewService(nil, nil, nil, &http.Client{Transport: transport}, slog.New(slog.DiscardHandler)) + svc := NewService(nil, nil, nil, nil, &http.Client{Transport: transport}, slog.New(slog.DiscardHandler)) _, err := svc.fetchMDBListEntries(context.Background(), "http://127.0.0.1:8096/", 0) if !errors.Is(err, collectionutil.ErrMDBListURL) { diff --git a/internal/usercollections/sync_owner_access_db_test.go b/internal/usercollections/sync_owner_access_db_test.go new file mode 100644 index 0000000000..0e83c8bf90 --- /dev/null +++ b/internal/usercollections/sync_owner_access_db_test.go @@ -0,0 +1,230 @@ +package usercollections + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "slices" + "strconv" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/access" + "github.com/Silo-Server/silo-server/internal/auth" + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/policy" + "github.com/Silo-Server/silo-server/internal/settingscontract" + "github.com/Silo-Server/silo-server/internal/settingskeys" + "github.com/Silo-Server/silo-server/internal/userstore" + "github.com/Silo-Server/silo-server/internal/userstore/pgstore" +) + +type staticTMDBList []catalog.TMDBCollectionEntry + +func (l staticTMDBList) GetList(context.Context, int, int) ([]catalog.TMDBCollectionEntry, error) { + return l, nil +} + +// TestImportFillsItsLimitWithTitlesItsOwnerCanAccessDB pins #1612: an +// imported personal collection fills its item limit only with titles its +// owner profile can access (allowed libraries and rating limit, not the +// libraries it hides from browsing), counts only those titles in its sync +// summary, and follows the owner's access on the next sync. +func TestImportFillsItsLimitWithTitlesItsOwnerCanAccessDB(t *testing.T) { + dsn := os.Getenv("SILO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("SILO_TEST_DATABASE_URL is not set") + } + ctx := t.Context() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + exec := func(t *testing.T, query string, args ...any) { + t.Helper() + if _, err := pool.Exec(ctx, query, args...); err != nil { + t.Fatal(err) + } + } + + suffix := time.Now().UnixNano() + var account, open, closed int + username := fmt.Sprintf("import-owner-access-%d", suffix) + if err := pool.QueryRow(ctx, `INSERT INTO users(username,role,email,password_hash) VALUES($1,'user',$2,'') RETURNING id`, + username, username+"@example.test").Scan(&account); err != nil { + t.Fatal(err) + } + for i, target := range []*int{&open, &closed} { + if err := pool.QueryRow(ctx, `INSERT INTO media_folders(type,name,enabled) VALUES('movies',$1,true) RETURNING id`, + fmt.Sprintf("import-owner-access-%d-%d", suffix, i)).Scan(target); err != nil { + t.Fatal(err) + } + } + + // The source list, in order. Entry 3 names a title the server lacks. + // A PG ceiling admits ages 0 and 8, not 17. + type title struct { + library int + age int + } + source := []*title{{closed, 0}, {open, 17}, {open, 0}, nil, {open, 8}, {open, 0}, {open, 0}} + tmdbBase := int(suffix % 1_000_000_000 * 10) + var entries staticTMDBList + contentIDs := make([]string, len(source)) + for i, ti := range source { + tmdb := tmdbBase + i + entries = append(entries, catalog.TMDBCollectionEntry{ID: tmdb, MediaType: "movie"}) + if ti == nil { + continue + } + contentIDs[i] = fmt.Sprintf("import-owner-access-%d-%d", suffix, i) + exec(t, `INSERT INTO media_items(content_id,type,title,tmdb_id,content_rating_age) VALUES($1,'movie',$1,$2,$3)`, + contentIDs[i], strconv.Itoa(tmdb), ti.age) + exec(t, `INSERT INTO media_item_libraries(content_id,media_folder_id) VALUES($1,$2)`, contentIDs[i], ti.library) + } + t.Cleanup(func() { + ctx := context.Background() + _, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id=ANY($1)`, contentIDs) + _, _ = pool.Exec(ctx, `DELETE FROM media_folders WHERE id=ANY($1)`, []int{open, closed}) + _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id=$1`, account) + _, _ = pool.Exec(ctx, `DELETE FROM user_collection_revisions WHERE user_id=$1`, account) + }) + + provider := pgstore.NewPostgresProvider(pool) + store, err := provider.ForUser(ctx, account) + if err != nil { + t.Fatal(err) + } + for _, id := range []string{"owner", "other"} { + if err := store.CreateProfile(ctx, userstore.Profile{ID: id, Name: id}); err != nil { + t.Fatal(err) + } + } + setOwner := func(t *testing.T, rating string, libraries []int) { + t.Helper() + restricted := libraries != nil + if err := store.UpdateProfile(ctx, "owner", userstore.UpdateProfileInput{ + MaxContentRating: &rating, LibraryRestrictionsEnabled: &restricted, AllowedLibraryIDs: &libraries, + }); err != nil { + t.Fatal(err) + } + } + hide := func(t *testing.T, libraries string) { + t.Helper() + if _, err := store.UpsertSettingValue(ctx, userstore.SettingIdentity{ + Key: settingskeys.UiDisabledLibraryIds, Scope: settingscontract.ScopeProfile, ProfileID: "owner", + }, json.RawMessage(libraries)); err != nil { + t.Fatal(err) + } + } + + engine, err := policy.NewEngine(ctx) + if err != nil { + t.Fatal(err) + } + resolver := policy.NewViewerResolver(auth.NewUserRepository(pool), provider, nil, policy.NewPDP(engine), access.NewGroupStore(pool)) + svc := NewService(provider, catalog.NewItemRepository(pool), catalog.NewLibraryItemRepository(pool), NewOwnerAccess(resolver), nil, slog.New(slog.DiscardHandler)) + svc.TMDBLists = entries + + collection, err := store.CreateCollection(ctx, userstore.CreateCollectionInput{ + CreatorProfileID: "owner", Name: "Imported", CollectionType: "tmdb", IsShared: true, + QueryDefinition: "{}", + SourceConfig: `{"mode":"tmdb_list","url":"https://www.themoviedb.org/list/310","limit":3}`, + }) + if err != nil { + t.Fatal(err) + } + + // sync runs the scheduled entry point, which knows only the account and + // collection, and checks the stored members and summary. + sync := func(t *testing.T, wantMessage string, wantUnmatched int, want ...int) { + t.Helper() + result, err := svc.SyncCollection(ctx, account, collection.ID) + if err != nil { + t.Fatal(err) + } + items, err := store.ListCollectionItems(ctx, collection.ID) + if err != nil { + t.Fatal(err) + } + var got, wantIDs []string + for _, item := range items { + got = append(got, item.MediaItemID) + } + for _, i := range want { + wantIDs = append(wantIDs, contentIDs[i]) + } + if !slices.Equal(got, wantIDs) { + t.Errorf("members = %v, want %v", got, wantIDs) + } + stored, err := store.GetCollection(ctx, collection.ID) + if err != nil { + t.Fatal(err) + } + if result.Message != wantMessage || stored.LastSyncMessage != wantMessage || stored.LastSyncStatus != "success" { + t.Errorf("summary = %q (stored %q, %s), want %q", result.Message, stored.LastSyncMessage, stored.LastSyncStatus, wantMessage) + } + if result.ItemsMatched != len(want) || result.ItemsUnmatched != wantUnmatched || stored.ItemCount != len(want) { + t.Errorf("matched %d, unmatched %d, stored count %d; want %d, %d, %d", + result.ItemsMatched, result.ItemsUnmatched, stored.ItemCount, len(want), wantUnmatched, len(want)) + } + } + + t.Run("a restricted owner's limit skips titles outside its access", func(t *testing.T) { + setOwner(t, "PG", []int{open}) + // Entries 0 (closed library) and 1 (rated 17) count as unmatched, + // like entry 3, which the server lacks. + sync(t, "Matched 3 of 7 entries (item limit reached after 6 scanned)", 3, 2, 4, 5) + }) + t.Run("the next sync follows the owner's wider access", func(t *testing.T) { + setOwner(t, "", nil) + sync(t, "Matched 3 of 7 entries (item limit reached after 3 scanned)", 0, 0, 1, 2) + }) + t.Run("the owner's hidden libraries still count as access", func(t *testing.T) { + setOwner(t, "", []int{open, closed}) + hide(t, fmt.Sprintf(`[%d]`, closed)) + t.Cleanup(func() { hide(t, `[]`) }) + sync(t, "Matched 3 of 7 entries (item limit reached after 3 scanned)", 0, 0, 1, 2) + }) + t.Run("chosen libraries and the owner's access both apply", func(t *testing.T) { + setOwner(t, "PG", nil) + if err := store.UpdateCollection(ctx, userstore.UpdateCollectionInput{ + ID: collection.ID, RequestProfileID: "owner", + SourceConfig: new(fmt.Sprintf(`{"mode":"tmdb_list","url":"https://www.themoviedb.org/list/310","limit":3,"library_ids":[%d]}`, open)), + }); err != nil { + t.Fatal(err) + } + sync(t, "Matched 3 of 7 entries (item limit reached after 6 scanned)", 3, 2, 4, 5) + }) + t.Run("a chosen library the owner can't access adds nothing", func(t *testing.T) { + // Entry 2 is in both libraries. The owner reaches it through the open + // one, but the collection asks for the closed one only, so it stays out. + exec(t, `INSERT INTO media_item_libraries(content_id,media_folder_id) VALUES($1,$2)`, contentIDs[2], closed) + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM media_item_libraries WHERE content_id=$1 AND media_folder_id=$2`, contentIDs[2], closed) + }) + setOwner(t, "", []int{open}) + if err := store.UpdateCollection(ctx, userstore.UpdateCollectionInput{ + ID: collection.ID, RequestProfileID: "owner", + SourceConfig: new(fmt.Sprintf(`{"mode":"tmdb_list","url":"https://www.themoviedb.org/list/310","limit":3,"library_ids":[%d]}`, closed)), + }); err != nil { + t.Fatal(err) + } + result, err := svc.SyncCollection(ctx, account, collection.ID) + if err != nil { + t.Fatal(err) + } + items, err := store.ListCollectionItems(ctx, collection.ID) + if err != nil { + t.Fatal(err) + } + if len(items) != 0 || result.ItemsMatched != 0 { + t.Fatalf("members = %+v (matched %d), want none", items, result.ItemsMatched) + } + }) +} diff --git a/internal/usercollections/sync_owner_access_test.go b/internal/usercollections/sync_owner_access_test.go new file mode 100644 index 0000000000..57156c56c8 --- /dev/null +++ b/internal/usercollections/sync_owner_access_test.go @@ -0,0 +1,137 @@ +package usercollections + +import ( + "context" + "errors" + "log/slog" + "slices" + "testing" + "time" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/userstore" +) + +// staticOwners answers one filter for every owner, or fails. +type staticOwners struct { + filter catalog.AccessFilter + err error + asked []string +} + +func (o *staticOwners) OwnerFilter(_ context.Context, _ int, owner string) (catalog.AccessFilter, error) { + o.asked = append(o.asked, owner) + return o.filter, o.err +} + +type countingTMDBListFetcher struct{ calls int } + +func (f *countingTMDBListFetcher) GetList(context.Context, int, int) ([]catalog.TMDBCollectionEntry, error) { + f.calls++ + return nil, nil +} + +func TestRunSyncFailsVisiblyWhenOwnerAccessIsUnavailable(t *testing.T) { + next := time.Date(2026, 10, 4, 6, 0, 0, 0, time.UTC) + importOf := func(creator string) *userstore.Collection { + return &userstore.Collection{ + ID: "c", + CreatorProfileID: creator, + SourceConfig: `{"mode":"tmdb_list","url":"https://www.themoviedb.org/list/310"}`, + ItemCount: 12, + NextSyncAt: &next, + } + } + cases := []struct { + name string + owners catalog.PersonalCollectionAccess + collection *userstore.Collection + }{ + {"the owner's access cannot be resolved", &staticOwners{err: errors.New("policy evaluation failed")}, importOf("owner")}, + {"owner access is not wired", nil, importOf("owner")}, + {"the collection has no owner", &staticOwners{}, importOf("")}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fetcher := &countingTMDBListFetcher{} + svc := NewService(nil, nil, nil, tc.owners, nil, slog.New(slog.DiscardHandler)) + svc.TMDBLists = fetcher + store := &mockSyncUserStore{} + + result, updated, err := svc.RunSync(t.Context(), 7, store, tc.collection) + if !errors.Is(err, ErrOwnerAccessUnavailable) { + t.Fatalf("RunSync error = %v, want ErrOwnerAccessUnavailable", err) + } + if result != nil || updated != nil { + t.Fatalf("RunSync returned a result %+v / %+v for a failed sync", result, updated) + } + if fetcher.calls != 0 { + t.Fatalf("fetched the source %d times without the owner's access", fetcher.calls) + } + if store.replacedItems != nil { + t.Fatalf("replaced the members with %v without the owner's access", store.replacedItems) + } + // The failure is recorded on the collection, so the owner sees it + // whether the sync ran on a schedule or by hand, and the members + // and next run are left as they were. + state := store.syncState + if state.ID != "c" || state.Status != "failed" || state.Message != ErrOwnerAccessUnavailable.Error() { + t.Fatalf("sync state = %+v, want a failed status with the owner access message", state) + } + if state.ItemCount != 12 || state.NextSyncAt == nil || !state.NextSyncAt.Equal(next) || state.LastSyncAt.IsZero() { + t.Fatalf("sync state = %+v, want item count 12, the same next sync and a sync time", state) + } + }) + } +} + +func TestRunSyncLeavesCollectionsWithoutASourceAlone(t *testing.T) { + owners := &staticOwners{err: errors.New("unused")} + svc := NewService(nil, nil, nil, owners, nil, slog.New(slog.DiscardHandler)) + store := &mockSyncUserStore{} + + _, _, err := svc.RunSync(t.Context(), 7, store, &userstore.Collection{ID: "c", CreatorProfileID: "owner"}) + if !errors.Is(err, ErrSyncUnsupported) { + t.Fatalf("RunSync error = %v, want ErrSyncUnsupported", err) + } + if len(owners.asked) != 0 || store.syncState.Status != "" { + t.Fatalf("a collection with no source resolved its owner (%v) or recorded a sync state (%+v)", owners.asked, store.syncState) + } +} + +func TestPickMembersFillsTheLimitWithAllowedTitles(t *testing.T) { + limit := func(n int) *int { return &n } + // One entry per source position; "" is an entry the catalog has no title for. + entries := []string{"hidden-1", "a", "", "hidden-2", "a", "b", "c", "d"} + allowed := map[string]bool{"a": true, "b": true, "c": true, "d": true} + + cases := []struct { + name string + limit *int + want []string + wantScanned int + wantUnmatched int + }{ + // Titles outside the owner's access count as unmatched, like titles + // the server lacks, and scanning continues past them. + {"limit reached", limit(3), []string{"a", "b", "c"}, 7, 3}, + {"limit not reached", limit(10), []string{"a", "b", "c", "d"}, 8, 3}, + {"no limit", nil, []string{"a", "b", "c", "d"}, 8, 3}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + members, scanned, unmatched := pickMembers(entries, allowed, tc.limit) + var got []string + for i, m := range members { + if m.Position != i { + t.Fatalf("member %d has position %d", i, m.Position) + } + got = append(got, m.MediaItemID) + } + if !slices.Equal(got, tc.want) || scanned != tc.wantScanned || unmatched != tc.wantUnmatched { + t.Fatalf("pickMembers = %v, scanned %d, unmatched %d; want %v, %d, %d", + got, scanned, unmatched, tc.want, tc.wantScanned, tc.wantUnmatched) + } + }) + } +} diff --git a/internal/usercollections/sync_status_test.go b/internal/usercollections/sync_status_test.go index 9bf855c28a..59dbf14aa1 100644 --- a/internal/usercollections/sync_status_test.go +++ b/internal/usercollections/sync_status_test.go @@ -9,12 +9,20 @@ import ( "github.com/Silo-Server/silo-server/internal/userstore" ) +// mockSyncUserStore holds one stored collection and applies the sync state +// the way the Postgres store does: the next run is written only while the +// stored schedule and next run still match the ones the sync started with. type mockSyncUserStore struct { userstore.UserStore + stored userstore.Collection replacedItems []userstore.CollectionItemReplacement syncState userstore.UpdateCollectionSyncStateInput } +func newMockSyncUserStore(stored userstore.Collection) *mockSyncUserStore { + return &mockSyncUserStore{stored: stored} +} + func (m *mockSyncUserStore) ReplaceCollectionItems(ctx context.Context, collectionID string, items []userstore.CollectionItemReplacement) error { m.replacedItems = items return nil @@ -22,17 +30,35 @@ func (m *mockSyncUserStore) ReplaceCollectionItems(ctx context.Context, collecti func (m *mockSyncUserStore) UpdateCollectionSyncState(ctx context.Context, input userstore.UpdateCollectionSyncStateInput) error { m.syncState = input + at := input.LastSyncAt + m.stored.LastSyncAt, m.stored.LastSyncStatus, m.stored.LastSyncMessage, m.stored.ItemCount = &at, input.Status, input.Message, input.ItemCount + if equalPtr(m.stored.SyncSchedule, input.ScheduleAtStart) && equalTimePtr(m.stored.NextSyncAt, input.NextSyncAtAtStart) { + m.stored.NextSyncAt = input.NextSyncAt + } return nil } +func (m *mockSyncUserStore) GetCollection(ctx context.Context, id string) (*userstore.Collection, error) { + c := m.stored + return &c, nil +} + +func equalPtr(a, b *string) bool { + return (a == nil) == (b == nil) && (a == nil || *a == *b) +} + +func equalTimePtr(a, b *time.Time) bool { + return (a == nil) == (b == nil) && (a == nil || a.Equal(*b)) +} + func TestApplyResult_UnmatchedItemsReportsSuccessStatus(t *testing.T) { t.Parallel() - svc := NewService(nil, nil, nil, nil, slog.New(slog.DiscardHandler)) - store := &mockSyncUserStore{} + svc := NewService(nil, nil, nil, nil, nil, slog.New(slog.DiscardHandler)) collection := &userstore.Collection{ ID: "test-col-1", } + store := newMockSyncUserStore(*collection) startedAt := time.Now().UTC().Add(-time.Second) matched := []userstore.CollectionItemReplacement{ @@ -70,3 +96,63 @@ func TestApplyResult_UnmatchedItemsReportsSuccessStatus(t *testing.T) { t.Errorf("updated.LastSyncStatus = %q, want %q", updated.LastSyncStatus, "success") } } + +// TestApplyResultSchedulesTheNextSyncInLocalTime pins a named schedule's next +// run to the node's local wall clock, the zone the collection capabilities +// report, even though the sync completes with a UTC timestamp. +func TestApplyResultSchedulesTheNextSyncInLocalTime(t *testing.T) { + local := time.FixedZone("UTC-11", -11*60*60) + saved := time.Local + time.Local = local + t.Cleanup(func() { time.Local = saved }) + + svc := NewService(nil, nil, nil, nil, nil, slog.New(slog.DiscardHandler)) + schedule := AllowedSyncSchedules["daily"] + collection := &userstore.Collection{ID: "test-col-1", SyncSchedule: &schedule} + store := newMockSyncUserStore(*collection) + + _, updated, err := svc.applyResult(context.Background(), store, collection, time.Now().UTC(), nil, 0, 0, 0) + if err != nil { + t.Fatalf("applyResult failed: %v", err) + } + next := updated.NextSyncAt + if next == nil || store.syncState.NextSyncAt == nil || !store.syncState.NextSyncAt.Equal(*next) { + t.Fatalf("next sync = %v, stored %v; want the same time", next, store.syncState.NextSyncAt) + } + // The daily schedule runs at 04:30 plus up to 15 minutes of jitter. + if wall := next.In(local); wall.Hour() != 4 || wall.Minute() < 30 || wall.Minute() >= 45 { + t.Errorf("next sync = %s local, want between 04:30 and 04:45", wall.Format(time.TimeOnly)) + } +} + +// TestApplyResultReturnsAScheduleEditedDuringTheSync pins the collection a +// sync returns to what the store kept: when the profile changed the schedule +// while the sync ran, the store keeps the edit, and so does the result the +// import or sync-now response is built from. +func TestApplyResultReturnsAScheduleEditedDuringTheSync(t *testing.T) { + t.Parallel() + + svc := NewService(nil, nil, nil, nil, nil, slog.New(slog.DiscardHandler)) + daily, weekly := AllowedSyncSchedules["daily"], AllowedSyncSchedules["weekly"] + started := time.Now().Add(-time.Minute).UTC() + collection := &userstore.Collection{ID: "test-col-1", SyncSchedule: &daily, NextSyncAt: &started} + + editedNext := time.Now().Add(6 * 24 * time.Hour).UTC() + edited := *collection + edited.SyncSchedule, edited.NextSyncAt = &weekly, &editedNext + store := newMockSyncUserStore(edited) + + _, updated, err := svc.applyResult(context.Background(), store, collection, started, nil, 0, 0, 0) + if err != nil { + t.Fatalf("applyResult failed: %v", err) + } + if updated.SyncSchedule == nil || *updated.SyncSchedule != weekly { + t.Errorf("sync_schedule = %v, want the edited %q", updated.SyncSchedule, weekly) + } + if updated.NextSyncAt == nil || !updated.NextSyncAt.Equal(editedNext) { + t.Errorf("next_sync_at = %v, want the edited %v", updated.NextSyncAt, editedNext) + } + if updated.LastSyncStatus != "success" { + t.Errorf("last_sync_status = %q, want success", updated.LastSyncStatus) + } +} diff --git a/internal/usercollections/sync_tmdb_list_test.go b/internal/usercollections/sync_tmdb_list_test.go index de90b4571d..cee0cb17bf 100644 --- a/internal/usercollections/sync_tmdb_list_test.go +++ b/internal/usercollections/sync_tmdb_list_test.go @@ -28,15 +28,16 @@ func TestSyncTMDBListFallsBackToSourceURLAndRedactsFetchErrors(t *testing.T) { URL: "https://api.themoviedb.org/3/list/310?page=1&api_key=secret-tmdb-key", Err: context.DeadlineExceeded, }} - svc := NewService(nil, nil, nil, nil, slog.New(slog.DiscardHandler)) + svc := NewService(nil, nil, nil, &staticOwners{}, nil, slog.New(slog.DiscardHandler)) svc.TMDBLists = fetcher collection := &userstore.Collection{ - ID: "c", - SourceURL: "https://www.themoviedb.org/list/310", - SourceConfig: `{"mode":"tmdb_list"}`, + ID: "c", + CreatorProfileID: "owner", + SourceURL: "https://www.themoviedb.org/list/310", + SourceConfig: `{"mode":"tmdb_list"}`, } - _, _, err := svc.RunSync(t.Context(), nil, collection) + _, _, err := svc.RunSync(t.Context(), 7, nil, collection) if fetcher.gotID != 310 { t.Fatalf("fetched list = %d, want 310 from source_url", fetcher.gotID) } @@ -50,11 +51,11 @@ func TestSyncTMDBListFallsBackToSourceURLAndRedactsFetchErrors(t *testing.T) { func TestSyncTMDBListRejectsNonListURL(t *testing.T) { fetcher := &recordingTMDBListFetcher{} - svc := NewService(nil, nil, nil, nil, slog.New(slog.DiscardHandler)) + svc := NewService(nil, nil, nil, &staticOwners{}, nil, slog.New(slog.DiscardHandler)) svc.TMDBLists = fetcher - collection := &userstore.Collection{ID: "c", SourceConfig: `{"mode":"tmdb_list","url":"https://www.themoviedb.org/movie/550"}`} + collection := &userstore.Collection{ID: "c", CreatorProfileID: "owner", SourceConfig: `{"mode":"tmdb_list","url":"https://www.themoviedb.org/movie/550"}`} - if _, _, err := svc.RunSync(t.Context(), nil, collection); err == nil { + if _, _, err := svc.RunSync(t.Context(), 7, nil, collection); err == nil { t.Fatal("RunSync succeeded for a non-list URL") } if fetcher.gotID != 0 { diff --git a/internal/usercollections/types.go b/internal/usercollections/types.go index 3ff2718478..a3146a4097 100644 --- a/internal/usercollections/types.go +++ b/internal/usercollections/types.go @@ -3,10 +3,10 @@ // library_collections subsystem). // // User collections live in user_personal_collections and use external sources -// (TMDB / Trakt / MDBList) the same way admin collections do, but resolved -// against the entire catalog. Per-user library access is enforced at read -// time by the catalog resolver, so the sync service does not have to scope -// item resolution to a particular set of libraries. +// (TMDB / Trakt / MDBList) the same way admin collections do. A sync keeps +// only titles the collection's owner profile can access (its libraries and +// rating limit), so an item limit fills with titles the owner can see. Catalog +// reads still limit what each viewer sees to that viewer's own access. package usercollections import ( @@ -50,7 +50,7 @@ type SourceConfig struct { ProfileID string `json:"profile_id,omitempty"` Limit *int `json:"limit,omitempty"` // LibraryIDs narrows sync resolution to these libraries. Empty/nil means - // resolve against every library the requesting user can access. + // resolve against every library the collection's owner can access. LibraryIDs []int `json:"library_ids,omitempty"` } diff --git a/internal/usercollections/validate.go b/internal/usercollections/validate.go index 8ad79b0697..9ab6907f8a 100644 --- a/internal/usercollections/validate.go +++ b/internal/usercollections/validate.go @@ -9,9 +9,12 @@ import ( ) // AllowedSyncSchedules maps a UI-friendly cadence label to the cron -// expression we persist. The set is fixed (no user-supplied cron) so we can -// guarantee the >= 24h minimum-interval cap without re-parsing the cron tree. -// All schedules fire at 04:30 UTC to spread load across the cluster. +// expression we persist. The set is fixed (no user-supplied cron) so no +// schedule runs more than once a day, which keeps provider quota bounded +// without re-parsing the cron tree. All schedules fire at 04:30 on the node's +// local clock (see catalog.ComputeNextSyncAtFrom), so across a daylight saving +// change two daily runs are 23 or 25 hours apart, plus up to 15 minutes of +// jitter either way. var AllowedSyncSchedules = map[string]string{ "daily": "30 4 * * *", "weekly": "30 4 * * 0", @@ -30,6 +33,26 @@ func ResolveSyncSchedule(label string) (*string, error) { return &expr, nil } +// cadenceCustom is the cadence of a stored schedule that is not one of +// AllowedSyncSchedules. +const cadenceCustom = "custom" + +// CadenceOf names a stored schedule's cadence: its AllowedSyncSchedules +// label, "" when the collection is not synced, and cadenceCustom for any +// other expression. +func CadenceOf(schedule string) string { + schedule = strings.Join(strings.Fields(schedule), " ") + if schedule == "" { + return "" + } + for label, expr := range AllowedSyncSchedules { + if expr == schedule { + return label + } + } + return cadenceCustom +} + func InitialNextSyncAt(schedule *string) *time.Time { if schedule == nil || *schedule == "" { return nil diff --git a/internal/usercollections/validate_test.go b/internal/usercollections/validate_test.go new file mode 100644 index 0000000000..7acf9ba097 --- /dev/null +++ b/internal/usercollections/validate_test.go @@ -0,0 +1,25 @@ +package usercollections + +import "testing" + +func TestCadenceOf(t *testing.T) { + cases := []struct { + schedule, want string + }{ + {"", ""}, + {" ", ""}, + {AllowedSyncSchedules["daily"], "daily"}, + {AllowedSyncSchedules["weekly"], "weekly"}, + {AllowedSyncSchedules["monthly"], "monthly"}, + {" 30 4 * * 0 ", "weekly"}, + // Expressions no cadence name produces, such as a sub-daily one. + {"*/15 * * * *", "custom"}, + {"0 4 * * *", "custom"}, + {"daily", "custom"}, + } + for _, tc := range cases { + if got := CadenceOf(tc.schedule); got != tc.want { + t.Errorf("CadenceOf(%q) = %q, want %q", tc.schedule, got, tc.want) + } + } +} diff --git a/internal/userdb/bridgeimport/collection_sharing_db_test.go b/internal/userdb/bridgeimport/collection_sharing_db_test.go new file mode 100644 index 0000000000..0d6a8d9fc4 --- /dev/null +++ b/internal/userdb/bridgeimport/collection_sharing_db_test.go @@ -0,0 +1,102 @@ +package bridgeimport + +import ( + "context" + "database/sql" + "fmt" + "maps" + "os" + "path/filepath" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/userdb/bridgeimport/testdata" +) + +// The collection writer applies #1615: a shared collection whose allow list +// names every source profile (its creator implied) stays shared; one shared +// with fewer profiles, or with nobody but its creator, is imported private. +// Imported rows are native, and the semantic digest verifies the converted +// values against the target. It runs in a rolled-back transaction on any +// migrated database. +func TestImportCollectionsAppliesSharingRuleDB(t *testing.T) { + dsn := os.Getenv("SILO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("SILO_TEST_DATABASE_URL is not set") + } + ctx := t.Context() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + + path := filepath.Join(t.TempDir(), "1.db") + source, err := testdata.NewSource(path, 1) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = source.Close() }) + const stamp = "2026-01-01T00:00:00Z" + for _, statement := range []string{ + `INSERT INTO profiles(id,name,is_primary,created_at,updated_at) VALUES('parent','parent',1,'` + stamp + `','` + stamp + `'),('child','child',0,'` + stamp + `','` + stamp + `'),('guest','guest',0,'` + stamp + `','` + stamp + `')`, + `INSERT INTO personal_collections VALUES + ('whole','parent','parent','Whole login','manual',1,'{}','{}','` + stamp + `','` + stamp + `'), + ('subset','parent','parent','Subset','manual',1,'{}','{}','` + stamp + `','` + stamp + `'), + ('creator','parent','parent','Creator only','manual',1,'{}','{}','` + stamp + `','` + stamp + `'), + ('private','child','child','Private','manual',0,'{}','{}','` + stamp + `','` + stamp + `')`, + `INSERT INTO personal_collection_profiles VALUES('whole','child'),('whole','guest'),('subset','parent'),('subset','child'),('creator','parent'),('private','child')`, + } { + if _, err := source.DB.Exec(statement); err != nil { + t.Fatal(err) + } + } + snapshot, err := source.DB.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = snapshot.Rollback() }) + + target, err := pool.Begin(ctx) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = target.Rollback(context.Background()) }) + var userID int + if err := target.QueryRow(ctx, `INSERT INTO users(username,role) VALUES($1,'user') RETURNING id`, fmt.Sprintf("bridge-sharing-%d", time.Now().UnixNano())).Scan(&userID); err != nil { + t.Fatal(err) + } + + verification, err := importTable(ctx, snapshot, target, userID, Mapping{Source: sourceCollections, Target: "user_" + sourceCollections}) + if err != nil { + t.Fatalf("import: %v", err) + } + if verification.Rows != 4 { + t.Fatalf("imported %d rows, want 4", verification.Rows) + } + rows, err := target.Query(ctx, `SELECT id, is_shared, native FROM user_personal_collections WHERE user_id=$1`, userID) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + got := map[string]bool{} + for rows.Next() { + var id string + var shared, native bool + if err := rows.Scan(&id, &shared, &native); err != nil { + t.Fatal(err) + } + if !native { + t.Fatalf("%s imported without the native mark", id) + } + got[id] = shared + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + if want := map[string]bool{"whole": true, "subset": false, "creator": false, "private": false}; !maps.Equal(got, want) { + t.Fatalf("imported is_shared = %v, want %v", got, want) + } +} diff --git a/internal/userdb/bridgeimport/import_contract.go b/internal/userdb/bridgeimport/import_contract.go index 30ce7d615f..7d13f08535 100644 --- a/internal/userdb/bridgeimport/import_contract.go +++ b/internal/userdb/bridgeimport/import_contract.go @@ -19,6 +19,8 @@ const ( sourceSectionOverrides = "profile_section_overrides" sourceOrderRevision = "personal_collection_order_revision" sourceSingleton = "singleton" + sourceCollections = "personal_collections" + sourceCollectionAudiences = "personal_collection_profiles" ) // ProgressTransition must establish the account's durable sync generation in diff --git a/internal/userdb/bridgeimport/import_db_test.go b/internal/userdb/bridgeimport/import_db_test.go index 5f94019675..88f20a6849 100644 --- a/internal/userdb/bridgeimport/import_db_test.go +++ b/internal/userdb/bridgeimport/import_db_test.go @@ -108,6 +108,9 @@ func newImportFixture(t *testing.T, pool *pgxpool.Pool) importFixture { {`INSERT INTO personal_collections VALUES('collection','parent','parent','Synthetic collection','manual',1,'{}','{}','2026-01-01T00:00:00Z','2026-01-01T00:00:00Z')`, nil}, {`INSERT INTO personal_collection_items VALUES('collection',?,3,'2026-01-01T00:00:00Z')`, []any{f.item}}, {`INSERT INTO personal_collection_profiles VALUES('collection','child')`, nil}, + // Shared with nobody but its creator: imported as private (#1615). + {`INSERT INTO personal_collections VALUES('creator-only','parent','parent','Creator only','manual',1,'{}','{}','2026-01-01T00:00:00Z','2026-01-01T00:00:00Z')`, nil}, + {`INSERT INTO personal_collection_profiles VALUES('creator-only','parent')`, nil}, {`INSERT INTO collection_sort_preferences VALUES('parent','user','collection','title','asc','2026-01-01T00:00:00Z')`, nil}, {`INSERT INTO audio_preferences VALUES('parent',?,0,'en','{}','2026-01-01T00:00:00Z')`, []any{f.item}}, {`INSERT INTO subtitle_preferences VALUES('parent',?,'en',0,'','off','{}',NULL,'2026-01-01T00:00:00Z')`, []any{f.item}}, @@ -182,6 +185,7 @@ func TestImportCompleteAccountDB(t *testing.T) { } } assertImportedStoreReads(t, pool, f) + assertImportedCollectionSharing(t, pool, f) if got := sourceDigestForTest(t, f.path); got != f.identity.SourceSHA256 { t.Fatal("source changed") } @@ -332,3 +336,28 @@ func TestImportTwoAccountsAndConcurrentReplayDB(t *testing.T) { t.Fatal("changed-source replay rotated generation") } } + +// A collection whose allow list names every source profile (its creator +// implied) is imported shared; one shared with fewer profiles is imported +// private, so nobody gains access its owner withheld. Imported collections are +// native, and no allow-list rows are written. +func assertImportedCollectionSharing(t *testing.T, pool *pgxpool.Pool, f importFixture) { + t.Helper() + for id, wantShared := range map[string]bool{"collection": true, "creator-only": false} { + var shared, native bool + if err := pool.QueryRow(t.Context(), "SELECT is_shared, native FROM user_personal_collections WHERE user_id=$1 AND id=$2", f.identity.AccountID, id).Scan(&shared, &native); err != nil { + t.Fatal(err) + } + if shared != wantShared || !native { + t.Fatalf("%s imported is_shared=%t native=%t, want is_shared=%t native=true", id, shared, native, wantShared) + } + } + var visibilityRows int + if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM user_personal_collection_profiles WHERE user_id=$1", f.identity.AccountID).Scan(&visibilityRows); err != nil || visibilityRows != 0 { + t.Fatalf("allow-list rows written: %d (%v)", visibilityRows, err) + } + listed, err := pgstoreForTest(t, pool, f.identity.AccountID).ListCollections(t.Context(), "child") + if err != nil || len(listed) != 1 || listed[0].ID != "collection" { + t.Fatalf("child lists %+v (%v), want only the shared collection", listed, err) + } +} diff --git a/internal/userdb/bridgeimport/preflight.go b/internal/userdb/bridgeimport/preflight.go index 00e655b0bb..333cafddac 100644 --- a/internal/userdb/bridgeimport/preflight.go +++ b/internal/userdb/bridgeimport/preflight.go @@ -26,9 +26,10 @@ type Mapping struct { // Manifest returns every persistent application table in the current SQLite schema. func Manifest() []Mapping { var result []Mapping - for _, name := range strings.Fields("profiles profile_allowed_libraries watch_progress watch_history favorites watchlist home_item_dismissals personal_collections personal_collection_items personal_collection_profiles collection_sort_preferences audio_preferences subtitle_preferences series_playback_preferences library_playback_preferences profile_onboarding") { + for _, name := range strings.Fields("profiles profile_allowed_libraries watch_progress watch_history favorites watchlist home_item_dismissals personal_collections personal_collection_items collection_sort_preferences audio_preferences subtitle_preferences series_playback_preferences library_playback_preferences profile_onboarding") { result = append(result, Mapping{name, "user_" + name, "preserve account-scoped identities, values, nulls, ordering and timestamps"}) } + result = append(result, Mapping{sourceCollectionAudiences, "", "consumed, not copied: a shared personal collection stays shared only when its allow list names every source profile (its creator implied); otherwise it is imported private"}) result = append(result, Mapping{"hidden_history_items", "user_history_hidden_items", "preserve hidden-before cutoffs"}) for _, name := range strings.Fields("user_settings user_device_settings user_devices user_setting_values user_setting_mutations user_setting_migration_rejects jellycompat_displayprefs") { result = append(result, Mapping{name, name, "add account identity; preserve semantic scope, revisions, replay and opaque values; remap integer surrogate IDs"}) @@ -145,7 +146,7 @@ func Inspect(ctx context.Context, path string, accountID int64) (Report, error) r.Blockers = append(r.Blockers, "unmapped source table requires classification") } else { delete(mappings, name) - if mapping.Target == "" && count > 0 { + if mapping.Target == "" && mapping.Source != sourceCollectionAudiences && count > 0 { r.Blockers = append(r.Blockers, fmt.Sprintf("nonempty %s requires legacy disposition", name)) } } diff --git a/internal/userdb/bridgeimport/source_schema.go b/internal/userdb/bridgeimport/source_schema.go index c7aa8ac74b..9e4b34933c 100644 --- a/internal/userdb/bridgeimport/source_schema.go +++ b/internal/userdb/bridgeimport/source_schema.go @@ -70,7 +70,7 @@ func validateSourceSchema(ctx context.Context, tx *sql.Tx) error { if err != nil || i != len(expected) { return fmt.Errorf("incomplete source columns in %s", mapping.Source) } - if mapping.Target == "" { + if mapping.Target == "" && mapping.Source != sourceCollectionAudiences { var count int64 // The identifier comes only from the fixed manifest, never a discovered name. if err := tx.QueryRowContext(ctx, `SELECT count(*) FROM "`+mapping.Source+`"`).Scan(&count); err != nil { diff --git a/internal/userdb/bridgeimport/store_reads_test.go b/internal/userdb/bridgeimport/store_reads_test.go index ffee7beb22..148e7345ab 100644 --- a/internal/userdb/bridgeimport/store_reads_test.go +++ b/internal/userdb/bridgeimport/store_reads_test.go @@ -69,3 +69,12 @@ func assertImportedStoreReads(t *testing.T, pool *pgxpool.Pool, fixture importFi t.Fatal("opaque setting changed") } } + +func pgstoreForTest(t *testing.T, pool *pgxpool.Pool, accountID int) userstore.UserStore { + t.Helper() + store, err := pgstore.NewPostgresProvider(pool).ForUser(t.Context(), accountID) + if err != nil { + t.Fatal(err) + } + return store +} diff --git a/internal/userdb/bridgeimport/table_writer.go b/internal/userdb/bridgeimport/table_writer.go index e3ec57d30b..0ef58cee13 100644 --- a/internal/userdb/bridgeimport/table_writer.go +++ b/internal/userdb/bridgeimport/table_writer.go @@ -52,6 +52,11 @@ func importTable(ctx context.Context, source *sql.Tx, target pgx.Tx, userID int, for _, column := range selected { targetColumns = append(targetColumns, column.Name) } + convert, extraColumns, extraValues, err := tableConversion(ctx, source, mapping.Source, selected) + if err != nil { + return TableVerification{}, err + } + targetColumns = append(targetColumns, extraColumns...) digest := sha256.New() var count int64 batch := make([][]any, 0, 256) @@ -95,9 +100,16 @@ func importTable(ctx context.Context, source *sql.Tx, target pgx.Tx, userID int, values = append(values, value) } } + if convert != nil { + if err := convert(values[1:]); err != nil { + return TableVerification{}, fmt.Errorf("unsupported value in %s", mapping.Source) + } + } + // The digest covers the converted values, which the target must hold. if err := hashProjectedRow(ctx, target, digest, mapping.Source, selected, values[1:]); err != nil { return TableVerification{}, err } + values = append(values, extraValues...) if mapping.Source == sourceCollectionRevisions || mapping.Source == sourceOrderRevision { if err := rebaseWitness(ctx, target, userID, mapping, values[1:]); err != nil { return TableVerification{}, err @@ -130,6 +142,70 @@ func importTable(ctx context.Context, source *sql.Tx, target pgx.Tx, userID int, return expected, nil } +// tableConversion returns how a source table's rows change on import: a +// conversion of the selected values, applied before the row digest, and +// target-only columns with constant values, written but not digested. +func tableConversion(ctx context.Context, source *sql.Tx, table string, selected []SourceColumn) (func([]any) error, []string, []any, error) { + if table != sourceCollections { + return nil, nil, nil, nil + } + convert, err := collectionSharingRule(ctx, source, selected) + // Imported collections are native: listed and read by native routes. + return convert, []string{"native"}, []any{true}, err +} + +// collectionSharingRule applies #1615 to imported collections: is_shared +// now shows a collection to every profile on the login, so a shared +// collection stays shared only when its allow list names every source +// profile, its creator implied. Shared with fewer profiles, it is imported +// private, so no profile gains access its owner withheld. +func collectionSharingRule(ctx context.Context, source *sql.Tx, selected []SourceColumn) (func([]any) error, error) { + idIndex, sharedIndex := -1, -1 + for i, column := range selected { + switch column.Name { + case "id": + idIndex = i + case "is_shared": + sharedIndex = i + } + } + if idIndex < 0 || sharedIndex < 0 { + return nil, errors.New("collection columns are incomplete") + } + rows, err := source.QueryContext(ctx, `SELECT c.id FROM personal_collections c + WHERE NOT EXISTS ( + SELECT 1 FROM profiles p + WHERE p.id <> c.creator_profile_id + AND NOT EXISTS (SELECT 1 FROM personal_collection_profiles v WHERE v.collection_id = c.id AND v.profile_id = p.id))`) + if err != nil { + return nil, importFailure(ctx, "cannot read source collection audiences") + } + defer rows.Close() //nolint:errcheck + wholeLogin := map[string]bool{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, importFailure(ctx, "cannot read source collection audiences") + } + wholeLogin[id] = true + } + if err := rows.Err(); err != nil { + return nil, importFailure(ctx, "cannot read source collection audiences") + } + return func(values []any) error { + shared, ok := values[sharedIndex].(bool) + if !ok { + return nil // NULL stays NULL; the target refuses it as before. + } + id, ok := values[idIndex].(string) + if !ok { + return errors.New("invalid collection id") + } + values[sharedIndex] = shared && wholeLogin[id] + return nil + }, nil +} + func remappedColumn(table, column string) bool { return (column == "id" && (table == "user_setting_values" || table == "user_setting_migration_rejects")) || (table == "watch_progress" && column == "synced_seq") || (table == sourceOrderRevision && column == sourceSingleton) } diff --git a/internal/userdb/collection_get_test.go b/internal/userdb/collection_get_test.go new file mode 100644 index 0000000000..dc182a47c7 --- /dev/null +++ b/internal/userdb/collection_get_test.go @@ -0,0 +1,11 @@ +package userdb + +import ( + "testing" + + "github.com/Silo-Server/silo-server/internal/userstore/storetest" +) + +func TestSQLiteGetMissingCollection(t *testing.T) { + storetest.RunGetMissingCollection(t, newConformanceStore(t)) +} diff --git a/internal/userdb/collection_holding_test.go b/internal/userdb/collection_holding_test.go new file mode 100644 index 0000000000..e65b7c17cf --- /dev/null +++ b/internal/userdb/collection_holding_test.go @@ -0,0 +1,11 @@ +package userdb + +import ( + "testing" + + "github.com/Silo-Server/silo-server/internal/userstore/storetest" +) + +func TestSQLiteManualCollectionsHolding(t *testing.T) { + storetest.RunManualCollectionsHolding(t, newConformanceStore(t)) +} diff --git a/internal/userdb/collection_membership.go b/internal/userdb/collection_membership.go new file mode 100644 index 0000000000..532a229afb --- /dev/null +++ b/internal/userdb/collection_membership.go @@ -0,0 +1,36 @@ +package userdb + +import ( + "context" + "fmt" + + "github.com/Silo-Server/silo-server/internal/userstore" +) + +var _ userstore.CollectionMembershipReader = (*SQLiteUserStore)(nil) + +// ManualCollectionsHolding reads, in one query, which of the profile's own +// manual collections hold the title. +func (s *SQLiteUserStore) ManualCollectionsHolding(ctx context.Context, creatorProfileID, mediaItemID string) ([]string, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT c.id FROM personal_collections c + WHERE c.creator_profile_id = ? AND c.collection_type = 'manual' + AND EXISTS ( + SELECT 1 FROM personal_collection_items i + WHERE i.collection_id = c.id AND i.media_item_id = ? + )`, + creatorProfileID, mediaItemID) + if err != nil { + return nil, fmt.Errorf("listing collections holding an item: %w", err) + } + defer func() { _ = rows.Close() }() + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scanning collection id: %w", err) + } + ids = append(ids, id) + } + return ids, rows.Err() +} diff --git a/internal/userdb/collection_mutations.go b/internal/userdb/collection_mutations.go index bd9db51e91..293df2b303 100644 --- a/internal/userdb/collection_mutations.go +++ b/internal/userdb/collection_mutations.go @@ -44,7 +44,7 @@ func (s *SQLiteUserStore) CollectionOrderRevision(ctx context.Context) (int64, e func (s *SQLiteUserStore) ReorderCollectionItemsIfRevision(context.Context, string, []string, int64) error { return userstore.ErrCollectionPagingUnsupported } -func (s *SQLiteUserStore) ReorderCollectionsIfRevision(context.Context, string, *string, []string, int64) error { +func (s *SQLiteUserStore) ReorderCollectionsIfRevision(context.Context, string, []string, int64) error { return userstore.ErrCollectionPagingUnsupported } func (s *SQLiteUserStore) UpdateCollectionGroupIfRevision(context.Context, string, *string, *string, *userstore.GroupSortMode, int64) (*userstore.CollectionGroup, error) { diff --git a/internal/userdb/collections.go b/internal/userdb/collections.go index b5ad8a285f..0c8d0adb5a 100644 --- a/internal/userdb/collections.go +++ b/internal/userdb/collections.go @@ -2,6 +2,7 @@ package userdb import ( "database/sql" + "errors" "fmt" "github.com/Silo-Server/silo-server/internal/userstore" @@ -27,15 +28,7 @@ func CreateCollection(db *sql.DB, input userstore.CreateCollectionInput) (*Colle if input.SortConfig == "" { input.SortConfig = "{}" } - allowedProfiles := normalizeAllowedProfiles(input.CreatorProfileID, input.AllowedProfileIDs, input.IsShared) - - tx, err := db.Begin() - if err != nil { - return nil, err - } - defer tx.Rollback() - - _, err = tx.Exec( + _, err := db.Exec( `INSERT INTO personal_collections ( id, profile_id, creator_profile_id, name, collection_type, is_shared, query_definition, sort_config, created_at, updated_at @@ -46,30 +39,18 @@ func CreateCollection(db *sql.DB, input userstore.CreateCollectionInput) (*Colle if err != nil { return nil, err } - for _, profileID := range allowedProfiles { - if _, err := tx.Exec( - `INSERT INTO personal_collection_profiles (collection_id, profile_id) VALUES (?, ?)`, - id, profileID, - ); err != nil { - return nil, err - } - } - if err := tx.Commit(); err != nil { - return nil, err - } return &Collection{ - ID: id, - ProfileID: input.CreatorProfileID, - CreatorProfileID: input.CreatorProfileID, - Name: input.Name, - CollectionType: input.CollectionType, - IsShared: input.IsShared, - AllowedProfileIDs: allowedProfiles, - QueryDefinition: input.QueryDefinition, - SortConfig: input.SortConfig, - CreatedAt: now, - UpdatedAt: now, + ID: id, + ProfileID: input.CreatorProfileID, + CreatorProfileID: input.CreatorProfileID, + Name: input.Name, + CollectionType: input.CollectionType, + IsShared: input.IsShared, + QueryDefinition: input.QueryDefinition, + SortConfig: input.SortConfig, + CreatedAt: now, + UpdatedAt: now, }, nil } @@ -82,26 +63,26 @@ func GetCollection(db *sql.DB, id string) (*Collection, error) { FROM personal_collections WHERE id = ?`, id, ).Scan(&c.ID, &c.ProfileID, &c.CreatorProfileID, &c.Name, &c.CollectionType, &isShared, &c.QueryDefinition, &c.SortConfig, &c.CreatedAt, &c.UpdatedAt) - if err != nil { - return nil, err + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("collection %s: %w", id, userstore.ErrCollectionNotFound) } - c.IsShared = isShared - c.AllowedProfileIDs, err = listCollectionProfiles(db, id) if err != nil { return nil, err } + c.IsShared = isShared return &c, nil } -// ListCollections returns all collections for a given profile, ordered by creation date. +// ListCollections returns the collections profileID may see: its own, then +// other profiles' shared collections grouped by creator, each by creation date. func ListCollections(db *sql.DB, profileID string) ([]Collection, error) { rows, err := db.Query( - `SELECT pc.id, pc.profile_id, pc.creator_profile_id, pc.name, pc.collection_type, pc.is_shared, - pc.query_definition, pc.sort_config, pc.created_at, pc.updated_at - FROM personal_collections pc - JOIN personal_collection_profiles pcp ON pcp.collection_id = pc.id - WHERE pcp.profile_id = ? ORDER BY pc.created_at ASC`, - profileID, + `SELECT id, profile_id, creator_profile_id, name, collection_type, is_shared, + query_definition, sort_config, created_at, updated_at + FROM personal_collections + WHERE creator_profile_id = ? OR is_shared + ORDER BY creator_profile_id = ? DESC, creator_profile_id ASC, created_at ASC, id ASC`, + profileID, profileID, ) if err != nil { return nil, err @@ -116,51 +97,7 @@ func ListCollections(db *sql.DB, profileID string) ([]Collection, error) { } collections = append(collections, c) } - if err := rows.Err(); err != nil { - return nil, err - } - if err := attachCollectionProfiles(db, profileID, collections); err != nil { - return nil, err - } - return collections, nil -} - -// attachCollectionProfiles fills AllowedProfileIDs for every collection with a -// single batched query, avoiding the N+1 round trip a per-collection lookup -// would create. -func attachCollectionProfiles(db *sql.DB, profileID string, collections []Collection) error { - if len(collections) == 0 { - return nil - } - rows, err := db.Query( - `SELECT allowed.collection_id, allowed.profile_id - FROM personal_collection_profiles AS visible - JOIN personal_collection_profiles AS allowed - ON allowed.collection_id = visible.collection_id - WHERE visible.profile_id = ? - ORDER BY allowed.profile_id ASC`, - profileID, - ) - if err != nil { - return err - } - defer rows.Close() - - byCollection := make(map[string][]string, len(collections)) - for rows.Next() { - var collectionID, profileID string - if err := rows.Scan(&collectionID, &profileID); err != nil { - return err - } - byCollection[collectionID] = append(byCollection[collectionID], profileID) - } - if err := rows.Err(); err != nil { - return err - } - for i := range collections { - collections[i].AllowedProfileIDs = byCollection[collections[i].ID] - } - return nil + return collections, rows.Err() } // UpdateCollection renames a collection and updates its updated_at timestamp. @@ -207,35 +144,6 @@ func UpdateCollection(db *sql.DB, input userstore.UpdateCollectionInput) error { return err } } - if input.AllowedProfileIDs != nil || input.IsShared != nil { - isShared := false - if input.IsShared != nil { - isShared = *input.IsShared - } else { - if err := tx.QueryRow(`SELECT is_shared FROM personal_collections WHERE id = ?`, input.ID).Scan(&isShared); err != nil { - return err - } - } - allowed := []string{} - if input.AllowedProfileIDs != nil { - allowed = *input.AllowedProfileIDs - } else { - allowed, err = listCollectionProfilesTx(tx, input.ID) - if err != nil { - return err - } - } - allowed = normalizeAllowedProfiles(creatorProfileID, allowed, isShared) - if _, err := tx.Exec(`DELETE FROM personal_collection_profiles WHERE collection_id = ?`, input.ID); err != nil { - return err - } - for _, profileID := range allowed { - if _, err := tx.Exec(`INSERT INTO personal_collection_profiles (collection_id, profile_id) VALUES (?, ?)`, input.ID, profileID); err != nil { - return err - } - } - } - return tx.Commit() } @@ -307,58 +215,3 @@ func ListCollectionItems(db *sql.DB, collectionID string) ([]CollectionItem, err } return items, rows.Err() } - -func listCollectionProfiles(db *sql.DB, collectionID string) ([]string, error) { - rows, err := db.Query( - `SELECT profile_id FROM personal_collection_profiles WHERE collection_id = ? ORDER BY profile_id ASC`, - collectionID, - ) - if err != nil { - return nil, err - } - defer rows.Close() - return scanCollectionProfiles(rows) -} - -func listCollectionProfilesTx(tx *sql.Tx, collectionID string) ([]string, error) { - rows, err := tx.Query( - `SELECT profile_id FROM personal_collection_profiles WHERE collection_id = ? ORDER BY profile_id ASC`, - collectionID, - ) - if err != nil { - return nil, err - } - defer rows.Close() - return scanCollectionProfiles(rows) -} - -func scanCollectionProfiles(rows *sql.Rows) ([]string, error) { - var profiles []string - for rows.Next() { - var profileID string - if err := rows.Scan(&profileID); err != nil { - return nil, err - } - profiles = append(profiles, profileID) - } - return profiles, rows.Err() -} - -func normalizeAllowedProfiles(creatorProfileID string, allowedProfiles []string, isShared bool) []string { - if !isShared { - return []string{creatorProfileID} - } - seen := map[string]struct{}{creatorProfileID: {}} - normalized := []string{creatorProfileID} - for _, profileID := range allowedProfiles { - if profileID == "" { - continue - } - if _, ok := seen[profileID]; ok { - continue - } - seen[profileID] = struct{}{} - normalized = append(normalized, profileID) - } - return normalized -} diff --git a/internal/userdb/conformance_test.go b/internal/userdb/conformance_test.go index 8e4dca4c43..4273556c0c 100644 --- a/internal/userdb/conformance_test.go +++ b/internal/userdb/conformance_test.go @@ -87,6 +87,10 @@ func TestSQLiteAtomicJellycompatProgressHistoryRollback(t *testing.T) { storetest.RunAtomicJellycompatProgress(t, store) } +func TestSQLiteCollectionSharing(t *testing.T) { + storetest.RunCollectionSharing(t, newConformanceStore) +} + // TestSQLiteProfilePINRevision runs the profile PIN revision conformance test // against the per-user SQLite backend; the Postgres backend runs the same suite // in internal/userstore/pgstore. diff --git a/internal/userdb/listing_batch_test.go b/internal/userdb/listing_batch_test.go index 2a11ba80a5..3fcdcbf837 100644 --- a/internal/userdb/listing_batch_test.go +++ b/internal/userdb/listing_batch_test.go @@ -4,8 +4,6 @@ import ( "database/sql" "reflect" "testing" - - "github.com/Silo-Server/silo-server/internal/userstore" ) func newListingTestDB(t *testing.T) *sql.DB { @@ -52,30 +50,6 @@ func TestListProfilesAttachesAllowedLibrariesInOrder(t *testing.T) { } } -func TestListCollectionsAttachesAllowedProfilesInOrder(t *testing.T) { - db := newListingTestDB(t) - collection, err := CreateCollection(db, userstore.CreateCollectionInput{ - CreatorProfileID: "p1", - Name: "Shared", - IsShared: true, - AllowedProfileIDs: []string{"p3", "p2"}, - }) - if err != nil { - t.Fatalf("CreateCollection: %v", err) - } - - collections, err := ListCollections(db, "p2") - if err != nil { - t.Fatalf("ListCollections: %v", err) - } - if len(collections) != 1 || collections[0].ID != collection.ID { - t.Fatalf("ListCollections = %+v, want collection %s", collections, collection.ID) - } - if got, want := collections[0].AllowedProfileIDs, []string{"p1", "p2", "p3"}; !reflect.DeepEqual(got, want) { - t.Fatalf("AllowedProfileIDs = %v, want %v", got, want) - } -} - func TestBatchAttachDoesNotScaleBindParametersWithRows(t *testing.T) { db := newListingTestDB(t) const aboveSQLiteVariableLimit = 32767 @@ -84,9 +58,4 @@ func TestBatchAttachDoesNotScaleBindParametersWithRows(t *testing.T) { if err := attachAllowedLibraries(db, profiles); err != nil { t.Fatalf("attachAllowedLibraries: %v", err) } - - collections := make([]Collection, aboveSQLiteVariableLimit) - if err := attachCollectionProfiles(db, "viewer", collections); err != nil { - t.Fatalf("attachCollectionProfiles: %v", err) - } } diff --git a/internal/userdb/migrate.go b/internal/userdb/migrate.go index 408d64d9f0..2119a7803e 100644 --- a/internal/userdb/migrate.go +++ b/internal/userdb/migrate.go @@ -5,7 +5,7 @@ import ( "fmt" ) -const schemaVersion = 30 +const schemaVersion = 31 func runMigrations(db *sql.DB) error { version, err := userVersion(db) @@ -22,7 +22,8 @@ func runMigrations(db *sql.DB) error { return setUserVersion(db, schemaVersion) } if version == schemaVersion { - // Already-v30 stores may have been opened before the trigger existed. + // Stores already at the current version may have been opened before + // the trigger existed. _, err := db.Exec(profilePINRevisionSchema) return err } @@ -292,7 +293,12 @@ func runMigrations(db *sql.DB) error { return err } } - if version < 30 { + // Builds of the collections revamp from before it merged main recorded + // login sharing as v30, without main's v30 pin_revision column. Such a + // store needs the column, and must not convert sharing a second time: + // collections shared since carry no allow list. + sharingApplied := version == 30 && !columnExists(tx, "profiles", "pin_revision") + if version < 30 || sharingApplied { // Same reasoning as v27: a fresh profiles table already has the column. // Profile tokens are bound to it; the Postgres store adds the same // column in 20261006192309_profile_pin_revision.sql. @@ -305,12 +311,43 @@ func runMigrations(db *sql.DB) error { return err } } + if version < 31 { + if !sharingApplied { + if err := applyCollectionLoginSharing(tx); err != nil { + return fmt.Errorf("migration v31 failed: %w", err) + } + } + if _, err := tx.Exec("PRAGMA user_version = 31"); err != nil { + return err + } + } if _, err := tx.Exec(profilePINRevisionSchema); err != nil { return fmt.Errorf("creating profile PIN revision trigger: %w", err) } return tx.Commit() } +// applyCollectionLoginSharing applies the #1615 rule: a shared collection +// is shown to every profile on the login, so it stays shared only when its +// allow list already covers every profile (its creator implied). Shared with +// some profiles, or with nobody but its creator, it becomes private. The allow-list table stays, no +// longer read, because the bridge importer's source contract names it. +func applyCollectionLoginSharing(tx *sql.Tx) error { + _, err := tx.Exec(` + UPDATE personal_collections + SET is_shared = 0 + WHERE is_shared + AND EXISTS ( + SELECT 1 FROM profiles p + WHERE p.id <> personal_collections.creator_profile_id + AND NOT EXISTS ( + SELECT 1 FROM personal_collection_profiles v + WHERE v.collection_id = personal_collections.id + AND v.profile_id = p.id + ))`) + return err +} + // retireProfileThemes deletes every stored ui.theme, ui.custom_theme_vars and // ui.custom_css value at every scope. The web client has one theme and applies // only the admin's overrides, so these choices are dead; the keys stay in the diff --git a/internal/userdb/migrate_v31_test.go b/internal/userdb/migrate_v31_test.go new file mode 100644 index 0000000000..502bc4d2c7 --- /dev/null +++ b/internal/userdb/migrate_v31_test.go @@ -0,0 +1,140 @@ +package userdb + +import ( + "database/sql" + "maps" + "testing" +) + +// An existing v30 store keeps a collection shared only when its allow list +// covers every profile; a collection shared with some profiles, or with +// nobody but its creator, becomes private (#1615). +func TestMigrateToV31AppliesLoginSharingRule(t *testing.T) { + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + db.SetMaxOpenConns(1) + t.Cleanup(func() { _ = db.Close() }) + if err := InitSchema(db); err != nil { + t.Fatalf("InitSchema: %v", err) + } + for _, id := range []string{"p1", "p2", "p3"} { + if err := CreateProfile(db, Profile{ID: id, Name: id, CreatedAt: "2026-01-01T00:00:00Z"}); err != nil { + t.Fatalf("CreateProfile(%s): %v", id, err) + } + } + const now = "2026-09-26T00:00:00Z" + for _, c := range []struct { + id string + shared bool + audience []string + }{ + {"all", true, []string{"p1", "p2", "p3"}}, + {"stale", true, []string{"p1", "p2", "p3", "gone"}}, + {"subset", true, []string{"p1", "p2"}}, + {"creator_only", true, []string{"p1"}}, + {"implied", true, []string{"p2", "p3"}}, + {"private", false, []string{"p1"}}, + } { + if _, err := db.Exec(`INSERT INTO personal_collections (id, profile_id, creator_profile_id, name, is_shared, created_at, updated_at) + VALUES (?, 'p1', 'p1', ?, ?, ?, ?)`, c.id, c.id, c.shared, now, now); err != nil { + t.Fatalf("seed collection %s: %v", c.id, err) + } + for _, profileID := range c.audience { + if _, err := db.Exec(`INSERT INTO personal_collection_profiles (collection_id, profile_id) VALUES (?, ?)`, c.id, profileID); err != nil { + t.Fatalf("seed allow list %s/%s: %v", c.id, profileID, err) + } + } + } + if _, err := db.Exec("PRAGMA user_version = 30"); err != nil { + t.Fatalf("set user_version: %v", err) + } + + if err := runMigrations(db); err != nil { + t.Fatalf("runMigrations: %v", err) + } + if version, err := userVersion(db); err != nil || version != schemaVersion { + t.Fatalf("user_version = %d, %v; want %d", version, err, schemaVersion) + } + + rows, err := db.Query(`SELECT id, is_shared FROM personal_collections`) + if err != nil { + t.Fatalf("query collections: %v", err) + } + defer func() { _ = rows.Close() }() + got := map[string]bool{} + for rows.Next() { + var id string + var shared bool + if err := rows.Scan(&id, &shared); err != nil { + t.Fatalf("scan: %v", err) + } + got[id] = shared + } + if err := rows.Err(); err != nil { + t.Fatalf("rows: %v", err) + } + want := map[string]bool{"all": true, "stale": true, "implied": true, "subset": false, "creator_only": false, "private": false} + if !maps.Equal(got, want) { + t.Fatalf("is_shared after v31 = %v, want %v", got, want) + } +} + +// A store a pre-merge collections revamp build opened is at v30 with login +// sharing already applied and without main's v30 pin_revision column. It gains +// the column and keeps collections shared since then, which have no allow list. +func TestMigrateRevampV30StoreAddsPINRevisionAndKeepsSharing(t *testing.T) { + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + db.SetMaxOpenConns(1) + t.Cleanup(func() { _ = db.Close() }) + if err := InitSchema(db); err != nil { + t.Fatalf("InitSchema: %v", err) + } + if err := runMigrations(db); err != nil { + t.Fatalf("runMigrations: %v", err) + } + for _, id := range []string{"p1", "p2"} { + if err := CreateProfile(db, Profile{ID: id, Name: id, CreatedAt: "2026-01-01T00:00:00Z"}); err != nil { + t.Fatalf("CreateProfile(%s): %v", id, err) + } + } + const now = "2026-10-05T00:00:00Z" + if _, err := db.Exec(`INSERT INTO personal_collections (id, profile_id, creator_profile_id, name, is_shared, created_at, updated_at) + VALUES ('shared', 'p1', 'p1', 'shared', 1, ?, ?)`, now, now); err != nil { + t.Fatalf("seed collection: %v", err) + } + for _, stmt := range []string{ + `DROP TRIGGER profiles_pin_revision`, + `ALTER TABLE profiles DROP COLUMN pin_revision`, + "PRAGMA user_version = 30", + } { + if _, err := db.Exec(stmt); err != nil { + t.Fatalf("%s: %v", stmt, err) + } + } + + if err := InitSchema(db); err != nil { + t.Fatalf("InitSchema on the revamp's v30: %v", err) + } + if err := runMigrations(db); err != nil { + t.Fatalf("runMigrations: %v", err) + } + if version, err := userVersion(db); err != nil || version != schemaVersion { + t.Fatalf("user_version = %d, %v; want %d", version, err, schemaVersion) + } + var revision int + if err := db.QueryRow(`SELECT pin_revision FROM profiles WHERE id = 'p1'`).Scan(&revision); err != nil { + t.Fatalf("read pin_revision: %v", err) + } + var shared bool + if err := db.QueryRow(`SELECT is_shared FROM personal_collections WHERE id = 'shared'`).Scan(&shared); err != nil { + t.Fatalf("read is_shared: %v", err) + } + if !shared { + t.Fatal("a collection shared after the revamp's v30 became private") + } +} diff --git a/internal/userdb/sqlitestore.go b/internal/userdb/sqlitestore.go index 693fcce0b6..57cec0b3b3 100644 --- a/internal/userdb/sqlitestore.go +++ b/internal/userdb/sqlitestore.go @@ -313,7 +313,7 @@ func (s *SQLiteUserStore) ReorderCollectionItems(_ context.Context, _ string, _ return fmt.Errorf("collection reordering is not supported on the SQLite user store") } -func (s *SQLiteUserStore) ReorderCollections(_ context.Context, _ string, _ *string, _ []string) error { +func (s *SQLiteUserStore) ReorderCollections(_ context.Context, _ string, _ []string) error { return fmt.Errorf("collection reordering is not supported on the SQLite user store") } diff --git a/internal/userstore/collection_membership.go b/internal/userstore/collection_membership.go new file mode 100644 index 0000000000..ece446c652 --- /dev/null +++ b/internal/userstore/collection_membership.go @@ -0,0 +1,12 @@ +package userstore + +import "context" + +// CollectionMembershipReader answers which collections hold a title in one +// read, so a picker can mark them without listing every collection's items. +// Callers still check that the viewer may access the title. +type CollectionMembershipReader interface { + // ManualCollectionsHolding returns the ids of creatorProfileID's own + // manual collections that hold mediaItemID, in no particular order. + ManualCollectionsHolding(ctx context.Context, creatorProfileID, mediaItemID string) ([]string, error) +} diff --git a/internal/userstore/collection_mutations.go b/internal/userstore/collection_mutations.go index f3102aa3d0..335120bc7a 100644 --- a/internal/userstore/collection_mutations.go +++ b/internal/userstore/collection_mutations.go @@ -14,7 +14,7 @@ type CollectionMutationStore interface { DeleteCollectionIfRevision(context.Context, string, int64) error ReorderCollectionItemsIfRevision(context.Context, string, []string, int64) error CollectionOrderRevision(context.Context) (int64, error) - ReorderCollectionsIfRevision(context.Context, string, *string, []string, int64) error + ReorderCollectionsIfRevision(context.Context, string, []string, int64) error UpdateCollectionGroupIfRevision(context.Context, string, *string, *string, *GroupSortMode, int64) (*CollectionGroup, error) DeleteCollectionGroupIfRevision(context.Context, string, int64) error ReorderCollectionGroupsIfRevision(context.Context, []string, int64) error diff --git a/internal/userstore/collection_pages.go b/internal/userstore/collection_pages.go index e432ac145c..786172ea06 100644 --- a/internal/userstore/collection_pages.go +++ b/internal/userstore/collection_pages.go @@ -55,6 +55,8 @@ type CollectionFeatures struct { Imports bool Artwork bool ItemReorder bool + // Description reports that the store persists a collection description. + Description bool } type CollectionFeatureProvider interface { diff --git a/internal/userstore/pgstore/collection_get_test.go b/internal/userstore/pgstore/collection_get_test.go new file mode 100644 index 0000000000..448886e4cf --- /dev/null +++ b/internal/userstore/pgstore/collection_get_test.go @@ -0,0 +1,36 @@ +package pgstore + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/userstore/storetest" +) + +func TestPostgresGetMissingCollection(t *testing.T) { + dsn := os.Getenv("SILO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("SILO_TEST_DATABASE_URL is not set") + } + ctx := t.Context() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatal(err) + } + defer pool.Close() + var uid int + if err := pool.QueryRow(ctx, `INSERT INTO users(username,role) VALUES($1,'user') RETURNING id`, fmt.Sprintf("collection-get-%d", time.Now().UnixNano())).Scan(&uid); err != nil { + t.Fatal(err) + } + defer func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id=$1`, uid) + _, _ = pool.Exec(context.Background(), `DELETE FROM user_collection_revisions WHERE user_id=$1`, uid) + _, _ = pool.Exec(context.Background(), `DELETE FROM user_collection_order_revisions WHERE user_id=$1`, uid) + }() + storetest.RunGetMissingCollection(t, newStore(pool, uid)) +} diff --git a/internal/userstore/pgstore/collection_holding_test.go b/internal/userstore/pgstore/collection_holding_test.go new file mode 100644 index 0000000000..8cc42f2ec2 --- /dev/null +++ b/internal/userstore/pgstore/collection_holding_test.go @@ -0,0 +1,51 @@ +package pgstore + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/userstore" + "github.com/Silo-Server/silo-server/internal/userstore/storetest" +) + +func TestPostgresManualCollectionsHolding(t *testing.T) { + dsn := os.Getenv("SILO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("SILO_TEST_DATABASE_URL is not set") + } + ctx := t.Context() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + account := func(name string) int { + t.Helper() + var uid int + if err := pool.QueryRow(ctx, `INSERT INTO users(username,role) VALUES($1,'user') RETURNING id`, fmt.Sprintf("collection-holding-%s-%d", name, time.Now().UnixNano())).Scan(&uid); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id=$1`, uid) + _, _ = pool.Exec(context.Background(), `DELETE FROM user_collection_revisions WHERE user_id=$1`, uid) + _, _ = pool.Exec(context.Background(), `DELETE FROM user_collection_order_revisions WHERE user_id=$1`, uid) + }) + return uid + } + // Another login's profile with the same id holds the same title in a + // manual collection; the account under test must not report it. + other := newStore(pool, account("other")) + c, err := other.CreateCollection(ctx, userstore.CreateCollectionInput{CreatorProfileID: "owner", Name: "Other login", CollectionType: "manual"}) + if err != nil { + t.Fatal(err) + } + if err := other.AddCollectionItem(ctx, c.ID, "title", 0); err != nil { + t.Fatal(err) + } + storetest.RunManualCollectionsHolding(t, newStore(pool, account("self"))) +} diff --git a/internal/userstore/pgstore/collection_lock_order_test.go b/internal/userstore/pgstore/collection_lock_order_test.go index 53fd250855..a2dbf605ad 100644 --- a/internal/userstore/pgstore/collection_lock_order_test.go +++ b/internal/userstore/pgstore/collection_lock_order_test.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "os" - "strings" "testing" "time" @@ -23,8 +22,7 @@ func TestCollectionMixedWriterLockOrderPostgres(t *testing.T) { if dsn == "" { t.Skip("SILO_TEST_DATABASE_URL is not set") } - for _, operation := range []string{"collection-update", "collection-wildcard", "group-update", "group-delete"} { - group := strings.HasPrefix(operation, "group-") + for _, operation := range []string{"collection-update", "collection-wildcard"} { t.Run(operation, func(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) defer cancel() @@ -52,21 +50,6 @@ func TestCollectionMixedWriterLockOrderPostgres(t *testing.T) { if err != nil { t.Fatal(err) } - if group { - g, err := store.CreateCollectionGroup(ctx, "group", "group", "manual") - if err != nil { - t.Fatal(err) - } - id = g.ID - if err := store.UpdateCollection(ctx, userstore.UpdateCollectionInput{ID: c.ID, RequestProfileID: "owner", GroupID: new(new(g.ID))}); err != nil { - t.Fatal(err) - } - table = "user_collection_groups" - revision, err = store.CollectionOrderRevision(ctx) - if err != nil { - t.Fatal(err) - } - } gate, err := pool.Begin(ctx) if err != nil { t.Fatal(err) @@ -103,17 +86,6 @@ func TestCollectionMixedWriterLockOrderPostgres(t *testing.T) { defer legacyPool.Close() defer guardPool.Close() run := func(s *PostgresUserStore, expected *int64) error { - if group { - if expected != nil && operation == "group-delete" { - return s.DeleteCollectionGroupIfRevision(ctx, id, *expected) - } - if expected == nil { - _, e := s.UpdateCollectionGroup(ctx, id, new("legacy"), nil, nil) - return e - } - _, e := s.UpdateCollectionGroupIfRevision(ctx, id, new("guarded"), nil, nil, *expected) - return e - } return s.UpdateCollection(ctx, userstore.UpdateCollectionInput{ID: id, RequestProfileID: "owner", Name: new("changed"), ExpectedRevision: expected}) } legacyDone := make(chan error, 1) diff --git a/internal/userstore/pgstore/collection_membership.go b/internal/userstore/pgstore/collection_membership.go new file mode 100644 index 0000000000..310213171d --- /dev/null +++ b/internal/userstore/pgstore/collection_membership.go @@ -0,0 +1,35 @@ +package pgstore + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" + + "github.com/Silo-Server/silo-server/internal/userstore" +) + +var _ userstore.CollectionMembershipReader = (*PostgresUserStore)(nil) + +// ManualCollectionsHolding reads, in one query, which of the profile's own +// manual collections hold the title. Each EXISTS probe is a primary-key +// lookup on the item table. +func (s *PostgresUserStore) ManualCollectionsHolding(ctx context.Context, creatorProfileID, mediaItemID string) ([]string, error) { + rows, err := s.pool.Query(ctx, + `SELECT c.id FROM user_personal_collections c + WHERE c.user_id = $1 AND c.creator_profile_id = $2 AND c.collection_type = 'manual' + AND EXISTS ( + SELECT 1 FROM user_personal_collection_items i + WHERE i.user_id = c.user_id AND i.collection_id = c.id + AND i.media_item_id = $3 AND i.sub_item_id = '' + )`, + s.userID, creatorProfileID, mediaItemID) + if err != nil { + return nil, fmt.Errorf("listing collections holding an item: %w", err) + } + ids, err := pgx.CollectRows(rows, pgx.RowTo[string]) + if err != nil { + return nil, fmt.Errorf("scanning collection ids: %w", err) + } + return ids, nil +} diff --git a/internal/userstore/pgstore/collection_mutations_test.go b/internal/userstore/pgstore/collection_mutations_test.go index 4dd795c7b0..0820c56940 100644 --- a/internal/userstore/pgstore/collection_mutations_test.go +++ b/internal/userstore/pgstore/collection_mutations_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/Silo-Server/silo-server/internal/collectionutil" "github.com/Silo-Server/silo-server/internal/userstore" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" @@ -24,7 +25,7 @@ func TestCollectionCASConcurrentPostgres(t *testing.T) { t.Fatal(err) } defer pool.Close() - for _, operation := range []string{"update", "delete", "items", "collections", "group_update", "group_delete", "groups"} { + for _, operation := range []string{"update", "delete", "items", "collections"} { t.Run(operation, func(t *testing.T) { ctx := t.Context() var uid int @@ -46,15 +47,11 @@ func TestCollectionCASConcurrentPostgres(t *testing.T) { t.Fatal(err) } } - g, err := s.CreateCollectionGroup(ctx, "group", "group", "manual") - if err != nil { - t.Fatal(err) - } revision, err := s.CollectionRevision(ctx, c.ID) if err != nil { t.Fatal(err) } - if operation == "collections" || operation == "group_update" || operation == "group_delete" || operation == "groups" { + if operation == "collections" { revision, err = s.CollectionOrderRevision(ctx) if err != nil { t.Fatal(err) @@ -70,14 +67,9 @@ func TestCollectionCASConcurrentPostgres(t *testing.T) { case "items": return store.ReorderCollectionItemsIfRevision(ctx, c.ID, []string{"b", "a"}, revision) case "collections": - return store.ReorderCollectionsIfRevision(ctx, "owner", nil, []string{c.ID}, revision) - case "group_update": - _, err := store.UpdateCollectionGroupIfRevision(ctx, g.ID, new("updated"), nil, nil, revision) - return err - case "group_delete": - return store.DeleteCollectionGroupIfRevision(ctx, g.ID, revision) + return store.ReorderCollectionsIfRevision(ctx, "owner", []string{c.ID}, revision) default: - return store.ReorderCollectionGroupsIfRevision(ctx, []string{g.ID}, revision) + panic("unknown collection operation: " + operation) } } start := make(chan struct{}) @@ -132,43 +124,60 @@ func TestCollectionCASOrderScopePostgres(t *testing.T) { _, _ = pool.Exec(context.Background(), `DELETE FROM user_collection_order_revisions WHERE user_id=$1`, uid) }() store := newStore(pool, uid) - visible, err := store.CreateCollection(ctx, userstore.CreateCollectionInput{CreatorProfileID: "owner", Name: "visible"}) - if err != nil { - t.Fatal(err) - } - hidden, err := store.CreateCollection(ctx, userstore.CreateCollectionInput{CreatorProfileID: "other", Name: "hidden"}) - if err != nil { - t.Fatal(err) - } - group, err := store.CreateCollectionGroup(ctx, "group", "group", "manual") - if err != nil { - t.Fatal(err) + create := func(creator, name string, shared bool) *userstore.Collection { + t.Helper() + c, err := store.CreateCollection(ctx, userstore.CreateCollectionInput{CreatorProfileID: creator, Name: name, IsShared: shared}) + if err != nil { + t.Fatal(err) + } + return c + } + first := create("owner", "first", false) + second := create("owner", "second", true) + theirShared := create("other", "their shared", true) + theirPrivate := create("other", "their private", false) + // Each profile's new collections append to its own order. + for _, c := range []struct { + got *userstore.Collection + want int + }{{first, 0}, {second, 1}, {theirShared, 0}, {theirPrivate, 1}} { + if c.got.SortOrder != c.want { + t.Fatalf("%s sort_order = %d, want %d", c.got.Name, c.got.SortOrder, c.want) + } } version, err := store.CollectionOrderRevision(ctx) if err != nil { t.Fatal(err) } - // Even a current account version does not authorize moving an invisible - // collection or moving rows between the requested group and another group. - for _, groupID := range []*string{nil, &group.ID} { - err := store.ReorderCollectionsIfRevision(ctx, "owner", groupID, []string{hidden.ID, visible.ID}, version) - if err == nil { - t.Fatal("reordered outside profile/group scope") + // Even a current account version does not let a profile move another + // profile's collection, whether that collection is shared with it or not. + for _, ids := range [][]string{ + {theirShared.ID, second.ID, first.ID}, + {theirPrivate.ID, second.ID, first.ID}, + {theirShared.ID}, + } { + err := store.ReorderCollectionsIfRevision(ctx, "owner", ids, version) + if !errors.Is(err, collectionutil.ErrOrderedIDsMismatch) { + t.Fatalf("reorder %v = %v, want ErrOrderedIDsMismatch", ids, err) } after, err := store.CollectionOrderRevision(ctx) if err != nil || after != version { t.Fatalf("failed reorder changed aggregate: %d -> %d (%v)", version, after, err) } } - got, err := store.GetCollection(ctx, hidden.ID) - if err != nil { - t.Fatal(err) - } - if got.GroupID != nil || got.SortOrder != hidden.SortOrder { - t.Fatalf("hidden collection mutated: %+v", got) + // The #1616 repro: reordering one profile's collections leaves every + // other profile's order alone. + if err := store.ReorderCollectionsIfRevision(ctx, "owner", []string{second.ID, first.ID}, version); err != nil { + t.Fatalf("own permutation rejected: %v", err) } - if err := store.ReorderCollectionsIfRevision(ctx, "owner", nil, []string{visible.ID}, version); err != nil { - t.Fatalf("correct visible permutation rejected: %v", err) + for id, want := range map[string]int{second.ID: 0, first.ID: 1, theirShared.ID: 0, theirPrivate.ID: 1} { + got, err := store.GetCollection(ctx, id) + if err != nil { + t.Fatal(err) + } + if got.SortOrder != want { + t.Fatalf("%s sort_order after reorder = %d, want %d", got.Name, got.SortOrder, want) + } } } diff --git a/internal/userstore/pgstore/collection_pages.go b/internal/userstore/pgstore/collection_pages.go index 6d79f41dbc..6f1852c3a0 100644 --- a/internal/userstore/pgstore/collection_pages.go +++ b/internal/userstore/pgstore/collection_pages.go @@ -65,8 +65,10 @@ func (s *PostgresUserStore) ListCollectionItemsPage(ctx context.Context, collect return page, nil } +// CollectionFeatures reports no personal collection groups: since #1615 each +// profile has one flat order of its own collections. func (s *PostgresUserStore) CollectionFeatures() userstore.CollectionFeatures { - return userstore.CollectionFeatures{Groups: true, Imports: true, Artwork: true, ItemReorder: true} + return userstore.CollectionFeatures{Imports: true, Artwork: true, ItemReorder: true, Description: true} } // CollectionRevision is a durable fence for access and definition reads made diff --git a/internal/userstore/pgstore/collection_sync_state_test.go b/internal/userstore/pgstore/collection_sync_state_test.go new file mode 100644 index 0000000000..7105617633 --- /dev/null +++ b/internal/userstore/pgstore/collection_sync_state_test.go @@ -0,0 +1,149 @@ +package pgstore + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/userstore" +) + +// TestPostgresCollectionSyncStateKeepsAScheduleEditedDuringTheSync pins the +// compare-and-set on sync completion: a sync writes next_sync_at only while +// the stored schedule and next_sync_at are the ones it started with, so an +// edit made while it ran, on any node, keeps the next_sync_at the edit +// computed, even when the edit saved the same cadence again. The rest of the +// sync state is written either way. +func TestPostgresCollectionSyncStateKeepsAScheduleEditedDuringTheSync(t *testing.T) { + dsn := os.Getenv("SILO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("SILO_TEST_DATABASE_URL is not set") + } + ctx := t.Context() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + var userID int + if err := pool.QueryRow(ctx, `INSERT INTO users (username,role) VALUES ($1,'user') RETURNING id`, fmt.Sprintf("collection-sync-state-%d", time.Now().UnixNano())).Scan(&userID); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.WithoutCancel(ctx), `DELETE FROM users WHERE id=$1`, userID) + _, _ = pool.Exec(context.WithoutCancel(ctx), `DELETE FROM user_collection_revisions WHERE user_id=$1`, userID) + }) + store := newStore(pool, userID) + const profile = "sync-state-profile" + if err := store.CreateProfile(ctx, userstore.Profile{ID: profile, Name: "Sync"}); err != nil { + t.Fatal(err) + } + daily, weekly := "30 4 * * *", "30 4 * * 0" + base := time.Date(2026, 10, 4, 4, 30, 0, 0, time.UTC) + dailyNext, weeklyNext, syncNext := base.Add(24*time.Hour), base.Add(7*24*time.Hour), base.Add(25*time.Hour) + editNext := base.Add(23 * time.Hour) + + create := func(t *testing.T) *userstore.Collection { + t.Helper() + c, err := store.CreateCollection(ctx, userstore.CreateCollectionInput{ + CreatorProfileID: profile, Name: "Synced", CollectionType: "mdblist", QueryDefinition: "{}", + SourceConfig: `{"mode":"mdblist","url":"https://mdblist.com/lists/user/list"}`, + SyncSchedule: &daily, NextSyncAt: &dailyNext, + }) + if err != nil { + t.Fatal(err) + } + return c + } + // finish completes a sync that started on start's schedule and next run. + finish := func(t *testing.T, start *userstore.Collection) *userstore.Collection { + t.Helper() + if err := store.UpdateCollectionSyncState(ctx, userstore.UpdateCollectionSyncStateInput{ + ID: start.ID, Status: "success", Message: "Matched 3 of 3 entries", ItemCount: 3, + LastSyncAt: base, NextSyncAt: &syncNext, + ScheduleAtStart: start.SyncSchedule, NextSyncAtAtStart: start.NextSyncAt, + }); err != nil { + t.Fatal(err) + } + c, err := store.GetCollection(ctx, start.ID) + if err != nil { + t.Fatal(err) + } + if c.LastSyncAt == nil || !c.LastSyncAt.Equal(base) || c.LastSyncStatus != "success" || c.LastSyncMessage != "Matched 3 of 3 entries" || c.ItemCount != 3 { + t.Fatalf("sync state not written: last %v %q %q, count %d", c.LastSyncAt, c.LastSyncStatus, c.LastSyncMessage, c.ItemCount) + } + return c + } + + t.Run("an unchanged schedule takes the sync's next run", func(t *testing.T) { + c := create(t) + got := finish(t, c) + if got.NextSyncAt == nil || !got.NextSyncAt.Equal(syncNext) { + t.Fatalf("next_sync_at = %v, want %v", got.NextSyncAt, syncNext) + } + }) + t.Run("a schedule turned off during the sync stays off", func(t *testing.T) { + c := create(t) + if err := store.UpdateCollection(ctx, userstore.UpdateCollectionInput{ID: c.ID, RequestProfileID: profile, ClearSyncSchedule: true, ClearNextSyncAt: true}); err != nil { + t.Fatal(err) + } + got := finish(t, c) + if got.SyncSchedule != nil || got.NextSyncAt != nil { + t.Fatalf("schedule %v, next_sync_at %v; want both null", got.SyncSchedule, got.NextSyncAt) + } + }) + t.Run("a schedule changed during the sync keeps its own next run", func(t *testing.T) { + c := create(t) + if err := store.UpdateCollection(ctx, userstore.UpdateCollectionInput{ID: c.ID, RequestProfileID: profile, SyncSchedule: &weekly, NextSyncAt: &weeklyNext}); err != nil { + t.Fatal(err) + } + got := finish(t, c) + if got.SyncSchedule == nil || *got.SyncSchedule != weekly || got.NextSyncAt == nil || !got.NextSyncAt.Equal(weeklyNext) { + t.Fatalf("schedule %v, next_sync_at %v; want %q, %v", got.SyncSchedule, got.NextSyncAt, weekly, weeklyNext) + } + }) + t.Run("a schedule turned on during the sync keeps its own next run", func(t *testing.T) { + c := create(t) + if err := store.UpdateCollection(ctx, userstore.UpdateCollectionInput{ID: c.ID, RequestProfileID: profile, ClearSyncSchedule: true, ClearNextSyncAt: true}); err != nil { + t.Fatal(err) + } + off, err := store.GetCollection(ctx, c.ID) + if err != nil { + t.Fatal(err) + } + if err := store.UpdateCollection(ctx, userstore.UpdateCollectionInput{ID: c.ID, RequestProfileID: profile, SyncSchedule: &weekly, NextSyncAt: &weeklyNext}); err != nil { + t.Fatal(err) + } + got := finish(t, off) + if got.NextSyncAt == nil || !got.NextSyncAt.Equal(weeklyNext) { + t.Fatalf("next_sync_at = %v, want %v", got.NextSyncAt, weeklyNext) + } + }) + t.Run("a schedule saved again during the sync keeps the edit's next run", func(t *testing.T) { + c := create(t) + if err := store.UpdateCollection(ctx, userstore.UpdateCollectionInput{ID: c.ID, RequestProfileID: profile, SyncSchedule: &daily, NextSyncAt: &editNext}); err != nil { + t.Fatal(err) + } + got := finish(t, c) + if got.NextSyncAt == nil || !got.NextSyncAt.Equal(editNext) { + t.Fatalf("next_sync_at = %v, want %v", got.NextSyncAt, editNext) + } + }) + t.Run("a schedule changed and changed back during the sync keeps the edit's next run", func(t *testing.T) { + c := create(t) + if err := store.UpdateCollection(ctx, userstore.UpdateCollectionInput{ID: c.ID, RequestProfileID: profile, SyncSchedule: &weekly, NextSyncAt: &weeklyNext}); err != nil { + t.Fatal(err) + } + if err := store.UpdateCollection(ctx, userstore.UpdateCollectionInput{ID: c.ID, RequestProfileID: profile, SyncSchedule: &daily, NextSyncAt: &editNext}); err != nil { + t.Fatal(err) + } + got := finish(t, c) + if got.SyncSchedule == nil || *got.SyncSchedule != daily || got.NextSyncAt == nil || !got.NextSyncAt.Equal(editNext) { + t.Fatalf("schedule %v, next_sync_at %v; want %q, %v", got.SyncSchedule, got.NextSyncAt, daily, editNext) + } + }) +} diff --git a/internal/userstore/pgstore/collection_visibility_test.go b/internal/userstore/pgstore/collection_visibility_test.go new file mode 100644 index 0000000000..20cb75ebc5 --- /dev/null +++ b/internal/userstore/pgstore/collection_visibility_test.go @@ -0,0 +1,44 @@ +package pgstore + +import ( + "testing" + + "github.com/Silo-Server/silo-server/internal/userstore" +) + +// Native routes never read Audiobookshelf rows, which share the table without +// the native mark, and never another login's collections, shared or not. +func TestCollectionVisibilityExcludesAudiobookshelfAndOtherLoginsPostgres(t *testing.T) { + pool, userID := newConstraintTestUser(t) + _, otherUserID := newConstraintTestUser(t) + ctx := t.Context() + store := newStore(pool, userID) + own, err := store.CreateCollection(ctx, userstore.CreateCollectionInput{CreatorProfileID: "p1", Name: "own"}) + if err != nil { + t.Fatal(err) + } + if _, err := newStore(pool, otherUserID).CreateCollection(ctx, userstore.CreateCollectionInput{CreatorProfileID: "p1", Name: "other login", IsShared: true}); err != nil { + t.Fatal(err) + } + // An Audiobookshelf playlist, public on its server: stored the way the + // audiobook stores write it, without the native mark. + if _, err := pool.Exec(ctx, `INSERT INTO user_personal_collections (id, user_id, profile_id, creator_profile_id, name, collection_type, is_shared) + VALUES ('abs-playlist', $1, 'p1', 'p1', 'ABS playlist', 'playlist', TRUE)`, userID); err != nil { + t.Fatal(err) + } + + for _, profileID := range []string{"p1", "p2"} { + listed, err := store.ListCollections(ctx, profileID) + if err != nil { + t.Fatal(err) + } + for _, c := range listed { + if c.ID != own.ID { + t.Fatalf("ListCollections(%s) returned %s (%s)", profileID, c.ID, c.Name) + } + } + } + if _, err := store.GetCollection(ctx, "abs-playlist"); err == nil { + t.Fatal("GetCollection returned an Audiobookshelf row") + } +} diff --git a/internal/userstore/pgstore/collections.go b/internal/userstore/pgstore/collections.go index 2c929b10f2..b4d76d09a7 100644 --- a/internal/userstore/pgstore/collections.go +++ b/internal/userstore/pgstore/collections.go @@ -73,26 +73,19 @@ func (s *PostgresUserStore) CreateCollection(ctx context.Context, input userstor if input.SourceConfig == "" { input.SourceConfig = "{}" } - allowedProfiles := normalizeCollectionProfiles(input.CreatorProfileID, input.AllowedProfileIDs, input.IsShared) - - tx, err := s.pool.Begin(ctx) - if err != nil { - return nil, fmt.Errorf("beginning collection create: %w", err) - } - defer tx.Rollback(ctx) //nolint:errcheck - + // The new collection goes to the end of its creator's own order. var sortOrder int - err = tx.QueryRow(ctx, + err := s.pool.QueryRow(ctx, `INSERT INTO user_personal_collections ( id, user_id, profile_id, creator_profile_id, name, description, collection_type, is_shared, query_definition, sort_config, source_url, source_config, sync_schedule, next_sync_at, - sort_order, display_query_definition, include_in_server_collections, poster_url, created_at, updated_at + sort_order, display_query_definition, include_in_server_collections, poster_url, created_at, updated_at, native ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, COALESCE(( SELECT MAX(sort_order) + 1 FROM user_personal_collections - WHERE user_id = $2 AND group_id IS NULL - ), 0), $15, $16, $17, $18, $19) + WHERE user_id = $2 AND creator_profile_id = $4 AND native + ), 0), $15, $16, $17, $18, $19, TRUE) RETURNING sort_order`, id, s.userID, input.CreatorProfileID, input.CreatorProfileID, input.Name, input.Description, input.CollectionType, input.IsShared, input.QueryDefinition, input.SortConfig, @@ -102,18 +95,6 @@ func (s *PostgresUserStore) CreateCollection(ctx context.Context, input userstor if err != nil { return nil, fmt.Errorf("creating collection: %w", err) } - for _, profileID := range allowedProfiles { - if _, err := tx.Exec(ctx, - `INSERT INTO user_personal_collection_profiles (user_id, collection_id, profile_id) - VALUES ($1, $2, $3)`, - s.userID, id, profileID, - ); err != nil { - return nil, fmt.Errorf("creating collection profile visibility: %w", err) - } - } - if err := tx.Commit(ctx); err != nil { - return nil, fmt.Errorf("committing collection create: %w", err) - } return &userstore.Collection{ ID: id, ProfileID: input.CreatorProfileID, @@ -122,7 +103,6 @@ func (s *PostgresUserStore) CreateCollection(ctx context.Context, input userstor Description: input.Description, CollectionType: input.CollectionType, IsShared: input.IsShared, - AllowedProfileIDs: allowedProfiles, QueryDefinition: input.QueryDefinition, SortConfig: input.SortConfig, SourceURL: input.SourceURL, @@ -138,47 +118,35 @@ func (s *PostgresUserStore) CreateCollection(ctx context.Context, input userstor }, nil } +// GetCollection reads a native collection of the login. Audiobookshelf rows, +// which share the table, are not found. func (s *PostgresUserStore) GetCollection(ctx context.Context, id string) (*userstore.Collection, error) { row := s.pool.QueryRow(ctx, `SELECT `+collectionSelectColumns+` - FROM user_personal_collections WHERE user_id = $1 AND id = $2`, + FROM user_personal_collections WHERE user_id = $1 AND id = $2 AND native`, s.userID, id, ) c, err := scanCollection(row) - if err == pgx.ErrNoRows { - return nil, fmt.Errorf("collection %s not found", id) + if errors.Is(err, pgx.ErrNoRows) { + return nil, fmt.Errorf("collection %s: %w", id, userstore.ErrCollectionNotFound) } if err != nil { return nil, fmt.Errorf("getting collection: %w", err) } - c.AllowedProfileIDs, err = s.listCollectionProfiles(ctx, id) - if err != nil { - return nil, err - } return c, nil } -// ListCollections fetches collections plus their allowed profile lists in one -// query using ARRAY_AGG, avoiding the N+1 round trip a naive per-row profile -// lookup would create. +// ListCollections lists the collections profileID may see: its own in its +// order, then other profiles' shared collections, grouped by creator and each +// in its creator's order. func (s *PostgresUserStore) ListCollections(ctx context.Context, profileID string) ([]userstore.Collection, error) { rows, err := s.pool.Query(ctx, - `SELECT `+collectionSelectColumns+`, - COALESCE( - (SELECT array_agg(p.profile_id ORDER BY p.profile_id) - FROM user_personal_collection_profiles p - WHERE p.user_id = upc.user_id AND p.collection_id = upc.id), - ARRAY[]::TEXT[] - ) AS allowed_profile_ids - FROM user_personal_collections upc + `SELECT `+collectionSelectColumns+` + FROM user_personal_collections WHERE user_id = $1 - AND EXISTS ( - SELECT 1 FROM user_personal_collection_profiles vp - WHERE vp.user_id = upc.user_id - AND vp.collection_id = upc.id - AND vp.profile_id = $2 - ) - ORDER BY sort_order ASC, created_at ASC, id ASC`, + AND native + AND (creator_profile_id = $2 OR is_shared) + ORDER BY (creator_profile_id = $2) DESC, creator_profile_id ASC, sort_order ASC, created_at ASC, id ASC`, s.userID, profileID, ) if err != nil { @@ -188,28 +156,11 @@ func (s *PostgresUserStore) ListCollections(ctx context.Context, profileID strin var collections []userstore.Collection for rows.Next() { - var c userstore.Collection - var ( - createdAt, updatedAt time.Time - nextSyncAt, lastSyncAt *time.Time - syncSchedule *string - allowed []string - ) - if err := rows.Scan( - &c.ID, &c.ProfileID, &c.CreatorProfileID, &c.Name, &c.Description, &c.CollectionType, &c.IsShared, - &c.QueryDefinition, &c.SortConfig, &c.SourceURL, &c.SourceConfig, &syncSchedule, &nextSyncAt, - &lastSyncAt, &c.LastSyncStatus, &c.LastSyncMessage, &c.DisplayQueryDefinition, &c.ItemCount, &c.IncludeInServerCollections, - &c.PosterURL, &c.PosterThumbhash, &c.SortOrder, &c.GroupID, &createdAt, &updatedAt, &allowed, - ); err != nil { + c, err := scanCollection(rows) + if err != nil { return nil, fmt.Errorf("scanning collection row: %w", err) } - c.SyncSchedule = syncSchedule - c.NextSyncAt = nextSyncAt - c.LastSyncAt = lastSyncAt - c.CreatedAt = timeToString(createdAt) - c.UpdatedAt = timeToString(updatedAt) - c.AllowedProfileIDs = allowed - collections = append(collections, c) + collections = append(collections, *c) } return collections, rows.Err() } @@ -236,7 +187,7 @@ func (s *PostgresUserStore) updateCollectionAttempt(ctx context.Context, input u var creatorProfileID string if err := tx.QueryRow(ctx, - `SELECT creator_profile_id FROM user_personal_collections WHERE user_id = $1 AND id = $2`, + `SELECT creator_profile_id FROM user_personal_collections WHERE user_id = $1 AND id = $2 AND native`, s.userID, input.ID, ).Scan(&creatorProfileID); err != nil { return fmt.Errorf("loading collection creator: %w", err) @@ -330,45 +281,6 @@ func (s *PostgresUserStore) updateCollectionAttempt(ctx context.Context, input u } } - if input.AllowedProfileIDs != nil || input.IsShared != nil { - isShared := false - if input.IsShared != nil { - isShared = *input.IsShared - } else { - if err := tx.QueryRow(ctx, - `SELECT is_shared FROM user_personal_collections WHERE user_id = $1 AND id = $2`, - s.userID, input.ID, - ).Scan(&isShared); err != nil { - return err - } - } - allowed := []string{} - if input.AllowedProfileIDs != nil { - allowed = *input.AllowedProfileIDs - } else { - allowed, err = s.listCollectionProfilesTx(ctx, tx, input.ID) - if err != nil { - return err - } - } - allowed = normalizeCollectionProfiles(creatorProfileID, allowed, isShared) - if _, err := tx.Exec(ctx, - `DELETE FROM user_personal_collection_profiles WHERE user_id = $1 AND collection_id = $2`, - s.userID, input.ID, - ); err != nil { - return err - } - for _, profileID := range allowed { - if _, err := tx.Exec(ctx, - `INSERT INTO user_personal_collection_profiles (user_id, collection_id, profile_id) - VALUES ($1, $2, $3)`, - s.userID, input.ID, profileID, - ); err != nil { - return err - } - } - } - if err := s.finishCollectionRevision(ctx, tx, input.ID, input.ExpectedRevision); err != nil { return err } @@ -481,16 +393,17 @@ func (s *PostgresUserStore) reorderCollectionItems(ctx context.Context, collecti return tx.Commit(ctx) } -// ReorderCollections sets each collection's sort_order to its index in the -// supplied list. The list must be a permutation of the user's collections in -// the supplied group. A nil groupID targets the implicit Ungrouped bucket. -func (s *PostgresUserStore) ReorderCollections(ctx context.Context, profileID string, groupID *string, orderedIDs []string) error { - return s.reorderCollections(ctx, profileID, groupID, orderedIDs, nil) +// ReorderCollections sets each of profileID's own collections' sort_order to +// its index in the supplied list. The list must be a permutation of exactly +// those collections: another profile's collection, shared or not, is a +// mismatch, and nobody else's order changes. +func (s *PostgresUserStore) ReorderCollections(ctx context.Context, profileID string, orderedIDs []string) error { + return s.reorderCollections(ctx, profileID, orderedIDs, nil) } -func (s *PostgresUserStore) ReorderCollectionsIfRevision(ctx context.Context, profileID string, groupID *string, orderedIDs []string, expected int64) error { - return s.runCollectionMutation(ctx, "", expected, func() error { return s.reorderCollections(ctx, profileID, groupID, orderedIDs, &expected) }) +func (s *PostgresUserStore) ReorderCollectionsIfRevision(ctx context.Context, profileID string, orderedIDs []string, expected int64) error { + return s.runCollectionMutation(ctx, "", expected, func() error { return s.reorderCollections(ctx, profileID, orderedIDs, &expected) }) } -func (s *PostgresUserStore) reorderCollections(ctx context.Context, profileID string, groupID *string, orderedIDs []string, expected *int64) error { +func (s *PostgresUserStore) reorderCollections(ctx context.Context, profileID string, orderedIDs []string, expected *int64) error { if collectionutil.HasDuplicateOrderedIDs(orderedIDs) { return fmt.Errorf("ordered_ids contains duplicates") } @@ -515,28 +428,14 @@ func (s *PostgresUserStore) reorderCollections(ctx context.Context, profileID st FROM supplied WHERE t.user_id = $2 AND t.id = supplied.id - AND t.group_id IS NOT DISTINCT FROM $4 - AND EXISTS ( - SELECT 1 - FROM user_personal_collection_profiles p - WHERE p.user_id = t.user_id - AND p.collection_id = t.id - AND p.profile_id = $5 - ) + AND t.creator_profile_id = $4 + AND t.native RETURNING 1 ) SELECT (SELECT count(*) FROM upd), (SELECT count(*) FROM user_personal_collections - WHERE user_id = $2 - AND group_id IS NOT DISTINCT FROM $4 - AND EXISTS ( - SELECT 1 - FROM user_personal_collection_profiles p - WHERE p.user_id = user_personal_collections.user_id - AND p.collection_id = user_personal_collections.id - AND p.profile_id = $5 - )) - `, orderedIDs, s.userID, nowUTC(), groupID, profileID).Scan(&updated, &total); err != nil { + WHERE user_id = $2 AND creator_profile_id = $4 AND native) + `, orderedIDs, s.userID, nowUTC(), profileID).Scan(&updated, &total); err != nil { return fmt.Errorf("reordering collections: %w", err) } if updated != len(orderedIDs) || updated != total { @@ -549,262 +448,47 @@ func (s *PostgresUserStore) reorderCollections(ctx context.Context, profileID st return tx.Commit(ctx) } -// ListCollectionGroups returns the user's explicit group rows. Collections -// with nil group_id fall into an implicit Ungrouped bucket and are not -// represented in this table. -func (s *PostgresUserStore) ListCollectionGroups(ctx context.Context) ([]userstore.CollectionGroup, error) { - rows, err := s.pool.Query(ctx, ` - SELECT id, name, slug, default_sort_mode, sort_order, created_at, updated_at - FROM user_collection_groups - WHERE user_id = $1 - ORDER BY sort_order ASC, name ASC, id ASC - `, s.userID) - if err != nil { - return nil, fmt.Errorf("listing user collection groups: %w", err) - } - defer rows.Close() +// Personal collections are flat. Keep the UserStore methods for the frozen +// bridge contract; handlers reject group operations through CollectionFeatures. +var errPersonalCollectionGroupsUnsupported = errors.New("personal collection groups are not supported") - var groups []userstore.CollectionGroup - for rows.Next() { - var g userstore.CollectionGroup - var createdAt, updatedAt time.Time - if err := rows.Scan(&g.ID, &g.Name, &g.Slug, &g.DefaultSortMode, &g.SortOrder, &createdAt, &updatedAt); err != nil { - return nil, fmt.Errorf("scanning user collection group: %w", err) - } - g.CreatedAt = timeToString(createdAt) - g.UpdatedAt = timeToString(updatedAt) - groups = append(groups, g) - } - return groups, rows.Err() +func (s *PostgresUserStore) ListCollectionGroups(context.Context) ([]userstore.CollectionGroup, error) { + return nil, errPersonalCollectionGroupsUnsupported } -func (s *PostgresUserStore) getCollectionGroup(ctx context.Context, id string) (*userstore.CollectionGroup, error) { - var g userstore.CollectionGroup - var createdAt, updatedAt time.Time - err := s.pool.QueryRow(ctx, ` - SELECT id, name, slug, default_sort_mode, sort_order, created_at, updated_at - FROM user_collection_groups - WHERE user_id = $1 AND id = $2 - `, s.userID, id).Scan(&g.ID, &g.Name, &g.Slug, &g.DefaultSortMode, &g.SortOrder, &createdAt, &updatedAt) - if err != nil { - if err == pgx.ErrNoRows { - return nil, fmt.Errorf("user collection group not found") - } - return nil, fmt.Errorf("getting user collection group: %w", err) - } - g.CreatedAt = timeToString(createdAt) - g.UpdatedAt = timeToString(updatedAt) - return &g, nil -} - -func (s *PostgresUserStore) EnsureCollectionGroup(ctx context.Context, id string) error { +func (s *PostgresUserStore) EnsureCollectionGroup(_ context.Context, id string) error { if id == "" { return nil } - var exists bool - if err := s.pool.QueryRow(ctx, ` - SELECT EXISTS ( - SELECT 1 FROM user_collection_groups WHERE user_id = $1 AND id = $2 - ) - `, s.userID, id).Scan(&exists); err != nil { - return fmt.Errorf("checking user collection group: %w", err) - } - if !exists { - return userstore.ErrCollectionGroupNotFound - } - return nil + return errPersonalCollectionGroupsUnsupported } -func (s *PostgresUserStore) CreateCollectionGroup(ctx context.Context, name, slug string, defaultSortMode userstore.GroupSortMode) (*userstore.CollectionGroup, error) { - if name == "" { - return nil, fmt.Errorf("group name cannot be empty") - } - if slug == "" { - slug = collectionutil.SlugifyGroupSlug(name) - } - if defaultSortMode == "" { - defaultSortMode = userstore.GroupSortManual - } - id := "ucg_" + generateUUID() - var g userstore.CollectionGroup - var createdAt, updatedAt time.Time - err := s.pool.QueryRow(ctx, ` - INSERT INTO user_collection_groups ( - user_id, label, title, id, name, slug, default_sort_mode, sort_order - ) - SELECT $1, $2, $3, $4, $3, $2, $5, - COALESCE((SELECT MAX(sort_order) + 1 FROM user_collection_groups WHERE user_id = $1), 0) - RETURNING id, name, slug, default_sort_mode, sort_order, created_at, updated_at - `, s.userID, slug, name, id, defaultSortMode).Scan(&g.ID, &g.Name, &g.Slug, &g.DefaultSortMode, &g.SortOrder, &createdAt, &updatedAt) - if err != nil { - return nil, fmt.Errorf("creating user collection group: %w", err) - } - g.CreatedAt = timeToString(createdAt) - g.UpdatedAt = timeToString(updatedAt) - return &g, nil +func (s *PostgresUserStore) CreateCollectionGroup(context.Context, string, string, userstore.GroupSortMode) (*userstore.CollectionGroup, error) { + return nil, errPersonalCollectionGroupsUnsupported } -func (s *PostgresUserStore) UpdateCollectionGroup(ctx context.Context, id string, name *string, slug *string, defaultSortMode *userstore.GroupSortMode) (*userstore.CollectionGroup, error) { - return s.updateCollectionGroup(ctx, id, name, slug, defaultSortMode, nil) -} -func (s *PostgresUserStore) UpdateCollectionGroupIfRevision(ctx context.Context, id string, name *string, slug *string, defaultSortMode *userstore.GroupSortMode, expected int64) (*userstore.CollectionGroup, error) { - var group *userstore.CollectionGroup - err := s.runCollectionMutation(ctx, "", expected, func() error { - var err error - group, err = s.updateCollectionGroup(ctx, id, name, slug, defaultSortMode, &expected) - return err - }) - return group, err -} -func (s *PostgresUserStore) updateCollectionGroup(ctx context.Context, id string, name *string, slug *string, defaultSortMode *userstore.GroupSortMode, expected *int64) (*userstore.CollectionGroup, error) { - sets := []string{} - args := []any{s.userID, id} - add := func(column string, value any) { - args = append(args, value) - sets = append(sets, fmt.Sprintf("%s = $%d", column, len(args))) - } - if name != nil { - add("name", *name) - add("title", *name) - } - if slug != nil { - add("slug", *slug) - add("label", *slug) - } - if defaultSortMode != nil { - add("default_sort_mode", *defaultSortMode) - } - if len(sets) == 0 && expected == nil { - return s.getCollectionGroup(ctx, id) - } - tx, err := s.pool.BeginTx(ctx, collectionMutationTxOptions(expected)) - if err != nil { - return nil, err - } - defer func() { _ = tx.Rollback(ctx) }() - if err := s.checkCollectionOrderRevision(ctx, tx, expected); err != nil { - return nil, err - } - add("updated_at", nowUTC()) - query := fmt.Sprintf(` - UPDATE user_collection_groups - SET %s - WHERE user_id = $1 AND id = $2 - RETURNING id, name, slug, default_sort_mode, sort_order, created_at, updated_at - `, strings.Join(sets, ", ")) - var g userstore.CollectionGroup - var createdAt, updatedAt time.Time - err = tx.QueryRow(ctx, query, args...).Scan(&g.ID, &g.Name, &g.Slug, &g.DefaultSortMode, &g.SortOrder, &createdAt, &updatedAt) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, fmt.Errorf("user collection group not found") - } - return nil, fmt.Errorf("updating user collection group: %w", err) - } - g.CreatedAt = timeToString(createdAt) - g.UpdatedAt = timeToString(updatedAt) - if err := s.finishCollectionOrderRevision(ctx, tx, expected); err != nil { - return nil, err - } - if err := tx.Commit(ctx); err != nil { - return nil, err - } - return &g, nil +func (s *PostgresUserStore) UpdateCollectionGroup(context.Context, string, *string, *string, *userstore.GroupSortMode) (*userstore.CollectionGroup, error) { + return nil, errPersonalCollectionGroupsUnsupported } -func (s *PostgresUserStore) DeleteCollectionGroup(ctx context.Context, id string) error { - return s.deleteCollectionGroup(ctx, id, nil) -} -func (s *PostgresUserStore) DeleteCollectionGroupIfRevision(ctx context.Context, id string, expected int64) error { - return s.runCollectionMutation(ctx, "", expected, func() error { return s.deleteCollectionGroup(ctx, id, &expected) }) +func (s *PostgresUserStore) UpdateCollectionGroupIfRevision(context.Context, string, *string, *string, *userstore.GroupSortMode, int64) (*userstore.CollectionGroup, error) { + return nil, errPersonalCollectionGroupsUnsupported } -func (s *PostgresUserStore) deleteCollectionGroup(ctx context.Context, id string, expected *int64) error { - tx, err := s.pool.BeginTx(ctx, collectionMutationTxOptions(expected)) - if err != nil { - return fmt.Errorf("beginning user collection group delete: %w", err) - } - defer tx.Rollback(ctx) //nolint:errcheck - if err := s.checkCollectionOrderRevision(ctx, tx, expected); err != nil { - return err - } - // Group updates take this row before their aggregate trigger. Take it before - // clearing member parents too, so mixed update/delete cannot invert the order. - // NO KEY UPDATE keeps foreign-key checks on concurrent parent assignments compatible. - var groupExists string - if err := tx.QueryRow(ctx, `SELECT id FROM user_collection_groups WHERE user_id=$1 AND id=$2 FOR NO KEY UPDATE`, s.userID, id).Scan(&groupExists); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return userstore.ErrCollectionGroupNotFound - } - return err - } - - if _, err := tx.Exec(ctx, ` - UPDATE user_personal_collections - SET group_id = NULL, updated_at = $3 - WHERE user_id = $1 AND group_id = $2 - `, s.userID, id, nowUTC()); err != nil { - return fmt.Errorf("clearing group_id on collections: %w", err) - } - tag, err := tx.Exec(ctx, ` - DELETE FROM user_collection_groups WHERE user_id = $1 AND id = $2 - `, s.userID, id) - if err != nil { - return fmt.Errorf("deleting user collection group: %w", err) - } - if tag.RowsAffected() == 0 { - return fmt.Errorf("user collection group not found") - } - if err := s.finishCollectionOrderRevision(ctx, tx, expected); err != nil { - return err - } - return tx.Commit(ctx) +func (s *PostgresUserStore) DeleteCollectionGroup(context.Context, string) error { + return errPersonalCollectionGroupsUnsupported } -func (s *PostgresUserStore) ReorderCollectionGroups(ctx context.Context, orderedIDs []string) error { - return s.reorderCollectionGroups(ctx, orderedIDs, nil) +func (s *PostgresUserStore) DeleteCollectionGroupIfRevision(context.Context, string, int64) error { + return errPersonalCollectionGroupsUnsupported } -func (s *PostgresUserStore) ReorderCollectionGroupsIfRevision(ctx context.Context, orderedIDs []string, expected int64) error { - return s.runCollectionMutation(ctx, "", expected, func() error { return s.reorderCollectionGroups(ctx, orderedIDs, &expected) }) -} -func (s *PostgresUserStore) reorderCollectionGroups(ctx context.Context, orderedIDs []string, expected *int64) error { - if collectionutil.HasDuplicateOrderedIDs(orderedIDs) { - return fmt.Errorf("ordered_ids contains duplicates") - } - tx, err := s.pool.BeginTx(ctx, collectionMutationTxOptions(expected)) - if err != nil { - return fmt.Errorf("beginning user collection groups reorder: %w", err) - } - defer tx.Rollback(ctx) //nolint:errcheck - if err := s.checkCollectionOrderRevision(ctx, tx, expected); err != nil { - return err - } +func (s *PostgresUserStore) ReorderCollectionGroups(context.Context, []string) error { + return errPersonalCollectionGroupsUnsupported +} - var updated, total int - if err := tx.QueryRow(ctx, ` - WITH supplied AS ( - SELECT id, pos FROM unnest($1::text[]) WITH ORDINALITY AS u(id, pos) - ), - upd AS ( - UPDATE user_collection_groups g - SET sort_order = supplied.pos - 1, updated_at = $3 - FROM supplied - WHERE g.user_id = $2 AND g.id = supplied.id - RETURNING 1 - ) - SELECT (SELECT count(*) FROM upd), - (SELECT count(*) FROM user_collection_groups WHERE user_id = $2) - `, orderedIDs, s.userID, nowUTC()).Scan(&updated, &total); err != nil { - return fmt.Errorf("reordering user collection groups: %w", err) - } - if updated != len(orderedIDs) || updated != total { - return collectionutil.ErrOrderedIDsMismatch - } - if err := s.finishCollectionOrderRevision(ctx, tx, expected); err != nil { - return err - } - return tx.Commit(ctx) +func (s *PostgresUserStore) ReorderCollectionGroupsIfRevision(context.Context, []string, int64) error { + return errPersonalCollectionGroupsUnsupported } func (s *PostgresUserStore) RemoveCollectionItem(ctx context.Context, collectionID, mediaItemID string) error { @@ -873,74 +557,17 @@ func (s *PostgresUserStore) UpdateCollectionSyncState(ctx context.Context, input _, err := s.pool.Exec(ctx, `UPDATE user_personal_collections SET last_sync_at = $1, last_sync_status = $2, last_sync_message = $3, - item_count = $4, next_sync_at = $5, updated_at = $6 + item_count = $4, updated_at = $6, + next_sync_at = CASE + WHEN sync_schedule IS NOT DISTINCT FROM $9 AND next_sync_at IS NOT DISTINCT FROM $10 THEN $5 + ELSE next_sync_at + END WHERE user_id = $7 AND id = $8`, input.LastSyncAt, input.Status, input.Message, input.ItemCount, input.NextSyncAt, - nowUTC(), s.userID, input.ID, + nowUTC(), s.userID, input.ID, input.ScheduleAtStart, input.NextSyncAtAtStart, ) if err != nil { return fmt.Errorf("updating collection sync state: %w", err) } return nil } - -func (s *PostgresUserStore) listCollectionProfiles(ctx context.Context, collectionID string) ([]string, error) { - rows, err := s.pool.Query(ctx, - `SELECT profile_id - FROM user_personal_collection_profiles - WHERE user_id = $1 AND collection_id = $2 - ORDER BY profile_id ASC`, - s.userID, collectionID, - ) - if err != nil { - return nil, fmt.Errorf("listing collection profiles: %w", err) - } - defer rows.Close() - return scanCollectionProfiles(rows) -} - -func (s *PostgresUserStore) listCollectionProfilesTx(ctx context.Context, tx pgx.Tx, collectionID string) ([]string, error) { - rows, err := tx.Query(ctx, - `SELECT profile_id - FROM user_personal_collection_profiles - WHERE user_id = $1 AND collection_id = $2 - ORDER BY profile_id ASC`, - s.userID, collectionID, - ) - if err != nil { - return nil, fmt.Errorf("listing collection profiles in tx: %w", err) - } - defer rows.Close() - return scanCollectionProfiles(rows) -} - -func scanCollectionProfiles(rows pgx.Rows) ([]string, error) { - var profiles []string - for rows.Next() { - var profileID string - if err := rows.Scan(&profileID); err != nil { - return nil, fmt.Errorf("scanning collection profile row: %w", err) - } - profiles = append(profiles, profileID) - } - return profiles, rows.Err() -} - -func normalizeCollectionProfiles(creatorProfileID string, allowedProfiles []string, isShared bool) []string { - if !isShared { - return []string{creatorProfileID} - } - seen := map[string]struct{}{creatorProfileID: {}} - normalized := []string{creatorProfileID} - for _, profileID := range allowedProfiles { - if profileID == "" { - continue - } - if _, ok := seen[profileID]; ok { - continue - } - seen[profileID] = struct{}{} - normalized = append(normalized, profileID) - } - return normalized -} diff --git a/internal/userstore/pgstore/episode_completion.go b/internal/userstore/pgstore/episode_completion.go index 3879cee4e8..bb068a9983 100644 --- a/internal/userstore/pgstore/episode_completion.go +++ b/internal/userstore/pgstore/episode_completion.go @@ -49,7 +49,7 @@ func (s *PostgresUserStore) episodeParentCompletion(ctx context.Context, profile ) ) AS completed FROM unnest($3::text[]) AS parents(parent_id)`, parentColumn) - rows, err := s.pool.Query(ctx, query, s.userID, profileID, parentIDs) + rows, err := s.pool.Query(ctx, query, profilePlanArgs(s.userID, profileID, parentIDs)...) if err != nil { return nil, fmt.Errorf("loading episode parent completion: %w", err) } diff --git a/internal/userstore/pgstore/pgstore.go b/internal/userstore/pgstore/pgstore.go index 282f090364..9d08d75cf5 100644 --- a/internal/userstore/pgstore/pgstore.go +++ b/internal/userstore/pgstore/pgstore.go @@ -4,6 +4,7 @@ import ( "time" "github.com/google/uuid" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/Silo-Server/silo-server/internal/userstore" @@ -27,6 +28,16 @@ func newStore(pool *pgxpool.Pool, userID int) *PostgresUserStore { return &PostgresUserStore{pool: pool, userID: userID} } +// profilePlanArgs plans a profile-scoped read for its bound values on every +// execution. A cached statement switches to a generic plan after five runs, +// and the generic plan's row estimate for (user_id, profile_id) is the +// average across profiles: for a profile with a long watch history it +// materializes every history row instead of probing the item index, turning +// a millisecond lookup into hundreds of milliseconds. +func profilePlanArgs(args ...any) []any { + return append([]any{pgx.QueryExecModeCacheDescribe}, args...) +} + func generateUUID() string { return uuid.New().String() } diff --git a/internal/userstore/pgstore/progress.go b/internal/userstore/pgstore/progress.go index acffca755b..0a8facd431 100644 --- a/internal/userstore/pgstore/progress.go +++ b/internal/userstore/pgstore/progress.go @@ -1395,7 +1395,7 @@ func (s *PostgresUserStore) ListCompletedHistoryItems(ctx context.Context, query `+completedHistoryVisibleSQL+` GROUP BY h.media_item_id ORDER BY h.media_item_id ASC`, - s.userID, query.ProfileID, includeSources, excludeSources, mediaItemIDs, + profilePlanArgs(s.userID, query.ProfileID, includeSources, excludeSources, mediaItemIDs)..., ) if err != nil { return nil, fmt.Errorf("listing completed history items: %w", err) diff --git a/internal/userstore/pgstore/settings_serialization_test.go b/internal/userstore/pgstore/settings_serialization_test.go index aa594f9dde..14513e7f3a 100644 --- a/internal/userstore/pgstore/settings_serialization_test.go +++ b/internal/userstore/pgstore/settings_serialization_test.go @@ -20,3 +20,10 @@ func TestPostgresPreferenceSnapshot(t *testing.T) { return newStore(pool, userID) }) } + +func TestPostgresCollectionSharing(t *testing.T) { + storetest.RunCollectionSharing(t, func(t *testing.T) userstore.UserStore { + pool, userID := newConstraintTestUser(t) + return newStore(pool, userID) + }) +} diff --git a/internal/userstore/store.go b/internal/userstore/store.go index 2a6f3ec0e7..de23c27756 100644 --- a/internal/userstore/store.go +++ b/internal/userstore/store.go @@ -185,9 +185,9 @@ type UserStore interface { ListCollectionItems(ctx context.Context, collectionID string) ([]CollectionItem, error) ReplaceCollectionItems(ctx context.Context, collectionID string, items []CollectionItemReplacement) error ReorderCollectionItems(ctx context.Context, collectionID string, orderedMediaItemIDs []string) error - // ReorderCollections scopes to the supplied group_id. A nil groupID means - // the implicit Ungrouped bucket. - ReorderCollections(ctx context.Context, profileID string, groupID *string, orderedIDs []string) error + // ReorderCollections replaces profileID's order of its own collections; + // orderedIDs must name each of them exactly once. + ReorderCollections(ctx context.Context, profileID string, orderedIDs []string) error UpdateCollectionSyncState(ctx context.Context, input UpdateCollectionSyncStateInput) error ListCollectionGroups(ctx context.Context) ([]CollectionGroup, error) EnsureCollectionGroup(ctx context.Context, id string) error diff --git a/internal/userstore/storetest/collection_get.go b/internal/userstore/storetest/collection_get.go new file mode 100644 index 0000000000..f8d8180b21 --- /dev/null +++ b/internal/userstore/storetest/collection_get.go @@ -0,0 +1,30 @@ +package storetest + +import ( + "errors" + "testing" + + "github.com/Silo-Server/silo-server/internal/userstore" +) + +// RunGetMissingCollection checks that reading a collection that never existed, +// or was deleted, reports userstore.ErrCollectionNotFound. A Home row that +// still names a deleted collection relies on it to tell an expected empty row +// apart from a failing store. +func RunGetMissingCollection(t *testing.T, store userstore.UserStore) { + t.Helper() + ctx := t.Context() + if _, err := store.GetCollection(ctx, "never-created"); !errors.Is(err, userstore.ErrCollectionNotFound) { + t.Fatalf("GetCollection(never created) error = %v, want ErrCollectionNotFound", err) + } + collection, err := store.CreateCollection(ctx, userstore.CreateCollectionInput{CreatorProfileID: "owner", Name: "Deleted", CollectionType: "manual"}) + if err != nil { + t.Fatal(err) + } + if err := store.DeleteCollection(ctx, collection.ID); err != nil { + t.Fatal(err) + } + if _, err := store.GetCollection(ctx, collection.ID); !errors.Is(err, userstore.ErrCollectionNotFound) { + t.Fatalf("GetCollection(deleted) error = %v, want ErrCollectionNotFound", err) + } +} diff --git a/internal/userstore/storetest/collection_holding.go b/internal/userstore/storetest/collection_holding.go new file mode 100644 index 0000000000..4a8b6a023b --- /dev/null +++ b/internal/userstore/storetest/collection_holding.go @@ -0,0 +1,78 @@ +package storetest + +import ( + "slices" + "testing" + + "github.com/Silo-Server/silo-server/internal/userstore" +) + +// RunManualCollectionsHolding covers the Add to collection ticks: of the +// account's collections, only the creator profile's own manual collections +// that hold the title are reported. Another profile's collection, shared or +// not, and a synced list holding the same title are left out. +func RunManualCollectionsHolding(t *testing.T, store userstore.UserStore) { + t.Helper() + const owner = "owner" + ctx := t.Context() + reader, ok := store.(userstore.CollectionMembershipReader) + if !ok { + t.Fatal("store does not implement collection membership reads") + } + create := func(creator, name, kind string, shared bool, items ...string) string { + t.Helper() + c, err := store.CreateCollection(ctx, userstore.CreateCollectionInput{ + CreatorProfileID: creator, Name: name, CollectionType: kind, IsShared: shared, + }) + if err != nil { + t.Fatal(err) + } + for i, item := range items { + if err := store.AddCollectionItem(ctx, c.ID, item, i); err != nil { + t.Fatal(err) + } + } + return c.ID + } + holds := create(owner, "Holds it", "manual", false, "other-title", "title") + sharedHolds := create(owner, "Shared, holds it", "manual", true, "title") + create(owner, "Lacks it", "manual", false, "other-title") + create(owner, "Empty", "manual", false) + create(owner, "Synced list", "mdblist", false, "title") + create("viewer", "Another profile's", "manual", true, "title") + + got, err := reader.ManualCollectionsHolding(ctx, owner, "title") + if err != nil { + t.Fatal(err) + } + slices.Sort(got) + want := []string{holds, sharedHolds} + slices.Sort(want) + if !slices.Equal(got, want) { + t.Fatalf("owner's collections holding title = %v, want %v", got, want) + } + + for _, tc := range []struct{ profile, item string }{ + {owner, "missing-title"}, + {"nobody", "title"}, + } { + got, err := reader.ManualCollectionsHolding(ctx, tc.profile, tc.item) + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Fatalf("%s's collections holding %s = %v, want none", tc.profile, tc.item, got) + } + } + + if err := store.RemoveCollectionItem(ctx, holds, "title"); err != nil { + t.Fatal(err) + } + got, err = reader.ManualCollectionsHolding(ctx, owner, "title") + if err != nil { + t.Fatal(err) + } + if !slices.Equal(got, []string{sharedHolds}) { + t.Fatalf("after removal = %v, want [%s]", got, sharedHolds) + } +} diff --git a/internal/userstore/storetest/suite.go b/internal/userstore/storetest/suite.go index 29eec74744..dc3388ed6f 100644 --- a/internal/userstore/storetest/suite.go +++ b/internal/userstore/storetest/suite.go @@ -858,3 +858,111 @@ func testProgressPage(t *testing.T, newStore func(t *testing.T) userstore.UserSt t.Fatalf("rest = %d rows, first %q", len(rest), rest[0].MediaItemID) } } + +// RunCollectionSharing runs the personal collection visibility and owner-only +// update conformance checks: a shared collection reaches every profile on the +// login, a private one only its creator (#1615). +func RunCollectionSharing(t *testing.T, newStore func(t *testing.T) userstore.UserStore) { + t.Run("CollectionSharing", func(t *testing.T) { + testCollectionSharing(t, newStore) + }) +} + +func sorted(ids []string) []string { + return slices.Sorted(slices.Values(ids)) +} + +func testCollectionSharing(t *testing.T, newStore func(t *testing.T) userstore.UserStore) { + ctx := context.Background() + store := newStore(t) + + if err := store.CreateProfile(ctx, userstore.Profile{ID: "p1", Name: "Owner"}); err != nil { + t.Fatalf("CreateProfile(p1): %v", err) + } + if err := store.CreateProfile(ctx, userstore.Profile{ID: "p2", Name: "Viewer"}); err != nil { + t.Fatalf("CreateProfile(p2): %v", err) + } + + shared, err := store.CreateCollection(ctx, userstore.CreateCollectionInput{ + CreatorProfileID: "p1", + Name: "Family Action", + CollectionType: "smart", + IsShared: true, + QueryDefinition: `{"match":"all","groups":[]}`, + }) + if err != nil { + t.Fatalf("CreateCollection(shared): %v", err) + } + private, err := store.CreateCollection(ctx, userstore.CreateCollectionInput{ + CreatorProfileID: "p1", + Name: "Just Mine", + }) + if err != nil { + t.Fatalf("CreateCollection(private): %v", err) + } + theirs, err := store.CreateCollection(ctx, userstore.CreateCollectionInput{ + CreatorProfileID: "p2", + Name: "Viewer's Own", + }) + if err != nil { + t.Fatalf("CreateCollection(theirs): %v", err) + } + + // A profile created after the collection was shared still sees it. + if err := store.CreateProfile(ctx, userstore.Profile{ID: "p3", Name: "Added Later"}); err != nil { + t.Fatalf("CreateProfile(p3): %v", err) + } + + listed := func(profileID string) []string { + t.Helper() + collections, err := store.ListCollections(ctx, profileID) + if err != nil { + t.Fatalf("ListCollections(%s): %v", profileID, err) + } + ids := make([]string, 0, len(collections)) + for _, c := range collections { + if !c.VisibleTo(profileID) { + t.Fatalf("ListCollections(%s) returned %s, which VisibleTo rejects", profileID, c.ID) + } + ids = append(ids, c.ID) + } + return ids + } + // A shared collection reaches every profile on the login, including one + // that no allow list ever named; a private one stays with its creator. + // Each profile's own collections come first. + if got, want := listed("p2"), []string{theirs.ID, shared.ID}; !slices.Equal(got, want) { + t.Fatalf("ListCollections(p2) = %v, want %v", got, want) + } + if got, want := listed("p3"), []string{shared.ID}; !slices.Equal(got, want) { + t.Fatalf("ListCollections(p3) = %v, want %v", got, want) + } + if got, want := listed("p1"), []string{shared.ID, private.ID}; !slices.Equal(sorted(got), sorted(want)) { + t.Fatalf("ListCollections(p1) = %v, want %v", got, want) + } + if got, err := store.GetCollection(ctx, private.ID); err != nil || got.VisibleTo("p2") || !got.VisibleTo("p1") { + t.Fatalf("private collection visibility = %+v, %v; want only its creator", got, err) + } + + rejectedName := "Not Allowed" + if err := store.UpdateCollection(ctx, userstore.UpdateCollectionInput{ + ID: shared.ID, + RequestProfileID: "p2", + Name: &rejectedName, + }); err == nil { + t.Fatal("expected creator-only UpdateCollection rejection") + } + + // Turning sharing off hides the collection from everyone but its creator. + notShared := false + if err := store.UpdateCollection(ctx, userstore.UpdateCollectionInput{ + ID: shared.ID, + RequestProfileID: "p1", + IsShared: ¬Shared, + }); err != nil { + t.Fatalf("UpdateCollection(is_shared=false): %v", err) + } + if got := listed("p3"); len(got) != 0 { + t.Fatalf("ListCollections(p3) after unsharing = %v, want none", got) + } +} diff --git a/internal/userstore/types.go b/internal/userstore/types.go index 072bc514c8..1c6e79650f 100644 --- a/internal/userstore/types.go +++ b/internal/userstore/types.go @@ -232,7 +232,9 @@ type WatchlistEntry struct { AddedAt string } -// Collection represents a user-created collection. +// Collection represents a user-created collection. CreatorProfileID owns it: +// only that profile changes it. IsShared shows it to every profile on the +// login, including profiles added later; otherwise only its creator sees it. type Collection struct { ID string ProfileID string @@ -241,7 +243,6 @@ type Collection struct { Description string CollectionType string IsShared bool - AllowedProfileIDs []string QueryDefinition string SortConfig string SourceURL string @@ -262,6 +263,14 @@ type Collection struct { UpdatedAt string } +// VisibleTo reports whether profileID, a profile on the collection's login, +// may read the collection: it created it, or the collection is shared. Stores +// only return collections of the login they serve, so this never crosses +// logins. +func (c *Collection) VisibleTo(profileID string) bool { + return c != nil && profileID != "" && (c.CreatorProfileID == profileID || c.IsShared) +} + type GroupSortMode string const ( @@ -291,7 +300,6 @@ type CreateCollectionInput struct { Description string CollectionType string IsShared bool - AllowedProfileIDs []string QueryDefinition string SortConfig string SourceURL string @@ -305,17 +313,16 @@ type CreateCollectionInput struct { type UpdateCollectionInput struct { // ExpectedRevision, when set, is atomically checked in the mutation transaction. - ExpectedRevision *int64 - ID string - RequestProfileID string - Name *string - Description *string - IsShared *bool - AllowedProfileIDs *[]string - QueryDefinition *string - SortConfig *string - SourceURL *string - SourceConfig *string + ExpectedRevision *int64 + ID string + RequestProfileID string + Name *string + Description *string + IsShared *bool + QueryDefinition *string + SortConfig *string + SourceURL *string + SourceConfig *string // SourceConfigPatch atomically merges only present top-level source config members. // Imported collections are supported only by the PostgreSQL store. SourceConfigPatch *string @@ -337,6 +344,14 @@ type UpdateCollectionSyncStateInput struct { ItemCount int LastSyncAt time.Time NextSyncAt *time.Time + // ScheduleAtStart and NextSyncAtAtStart are the collection's sync_schedule + // and next_sync_at when the sync began. NextSyncAt is written only while + // the stored row still holds both, so a schedule edited during the sync, + // on any node, keeps the next_sync_at the edit wrote, even when the edit + // saved the same cadence again. The rest of the sync state is written + // either way. + ScheduleAtStart *string + NextSyncAtAtStart *time.Time } type CollectionItemReplacement struct { diff --git a/migrations/personal_collection_login_sharing_test.go b/migrations/personal_collection_login_sharing_test.go new file mode 100644 index 0000000000..a6afb5e98f --- /dev/null +++ b/migrations/personal_collection_login_sharing_test.go @@ -0,0 +1,180 @@ +package migrations + +import ( + "maps" + "slices" + "testing" +) + +const personalCollectionLoginSharingMigration = "20261003235347_personal_collection_login_sharing" + +// The #1615 migration keeps a collection shared only when its allow list +// covers every profile of its login, marks native rows, flattens each +// creator's order (group order, then position, ungrouped last) and deletes +// the groups. Audiobookshelf rows, which never had allow-list rows, stay +// untouched and non-native. +func TestPersonalCollectionLoginSharingPostgres(t *testing.T) { + tx, schema := adminMigrationFixture(t) + migrationExec(t, tx, ` +CREATE TABLE user_profiles (user_id integer NOT NULL, id text NOT NULL, PRIMARY KEY (user_id, id)); +CREATE TABLE user_collection_groups ( + user_id integer NOT NULL, + id text NOT NULL, + name text NOT NULL, + sort_order integer NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, id) +); +CREATE TABLE user_personal_collections ( + user_id integer NOT NULL, + id text NOT NULL, + creator_profile_id text NOT NULL, + collection_type text NOT NULL DEFAULT 'manual', + is_shared boolean NOT NULL DEFAULT false, + sort_order integer NOT NULL DEFAULT 0, + group_id text, + created_at timestamptz NOT NULL, + PRIMARY KEY (user_id, id), + FOREIGN KEY (user_id, group_id) REFERENCES user_collection_groups (user_id, id) ON DELETE SET NULL (group_id) +); +CREATE TABLE user_personal_collection_profiles ( + user_id integer NOT NULL, + collection_id text NOT NULL, + profile_id text NOT NULL, + PRIMARY KEY (user_id, collection_id, profile_id) +); +INSERT INTO user_profiles VALUES (1, 'p1'), (1, 'p2'), (1, 'p3'), (2, 'q1'); +INSERT INTO user_collection_groups VALUES + (1, 'g_b', 'B', 0), (1, 'g_a', 'A', 0), (1, 'g_c', 'C', 1), (2, 'g_q', 'Q', 0); +INSERT INTO user_personal_collections (user_id, id, creator_profile_id, collection_type, is_shared, sort_order, group_id, created_at) VALUES + (1, 'all', 'p1', 'manual', true, 0, 'g_b', '2020-01-01'), + (1, 'subset', 'p1', 'smart', true, 5, 'g_a', '2020-01-02'), + (1, 'stale', 'p1', 'manual', true, 2, 'g_a', '2020-01-03'), + (1, 'private', 'p1', 'manual', false, 0, NULL, '2020-01-04'), + (1, 'private_late', 'p1', 'mdblist', false, 1, NULL, '2020-01-05'), + (1, 'creator_only', 'p2', 'manual', true, 7, NULL, '2020-01-06'), + (1, 'implied', 'p3', 'manual', true, 0, NULL, '2020-01-06'), + (1, 'p2_grouped', 'p2', 'manual', false, 3, 'g_c', '2020-01-07'), + (1, 'abs_public', 'p1', 'playlist', true, 9, NULL, '2020-01-08'), + (1, 'abs_private', 'p2', 'smart', false, 4, NULL, '2020-01-09'), + (2, 'solo', 'q1', 'manual', true, 3, 'g_q', '2020-01-10'); +INSERT INTO user_personal_collection_profiles VALUES + (1, 'all', 'p1'), (1, 'all', 'p2'), (1, 'all', 'p3'), + (1, 'subset', 'p1'), (1, 'subset', 'p2'), + (1, 'stale', 'p1'), (1, 'stale', 'p2'), (1, 'stale', 'p3'), (1, 'stale', 'p_deleted'), + (1, 'private', 'p1'), + (1, 'private_late', 'p1'), + (1, 'creator_only', 'p2'), + (1, 'implied', 'p1'), (1, 'implied', 'p2'), + (1, 'p2_grouped', 'p2'), + (2, 'solo', 'q1');`) + + type row struct { + Native, Shared bool + SortOrder int + Grouped bool + } + read := func() map[string]row { + t.Helper() + rows, err := tx.Query(t.Context(), `SELECT id, native, is_shared, sort_order, group_id IS NOT NULL FROM user_personal_collections`) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + got := map[string]row{} + for rows.Next() { + var id string + var r row + if err := rows.Scan(&id, &r.Native, &r.Shared, &r.SortOrder, &r.Grouped); err != nil { + t.Fatal(err) + } + got[id] = r + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + return got + } + + up := adminMigrationSQL(t, personalCollectionLoginSharingMigration, schema, false) + migrationExec(t, tx, up) + + want := map[string]row{ + // p1: group A (stale, subset), group B (all), then ungrouped. + "stale": {Native: true, Shared: true, SortOrder: 0}, + "subset": {Native: true, Shared: false, SortOrder: 1}, + "all": {Native: true, Shared: true, SortOrder: 2}, + "private": {Native: true, Shared: false, SortOrder: 3}, + "private_late": {Native: true, Shared: false, SortOrder: 4}, + // p2: group C first, then ungrouped. Shared with nobody else: private. + "p2_grouped": {Native: true, Shared: false, SortOrder: 0}, + "creator_only": {Native: true, Shared: false, SortOrder: 1}, + // Every other profile is listed; the creator is implied. + "implied": {Native: true, Shared: true, SortOrder: 0}, + // Audiobookshelf rows keep every stored value. + "abs_public": {Native: false, Shared: true, SortOrder: 9}, + "abs_private": {Native: false, Shared: false, SortOrder: 4}, + // The only profile of its login covers the whole login: stays shared. + "solo": {Native: true, Shared: true, SortOrder: 0}, + } + if got := read(); !maps.Equal(got, want) { + t.Fatalf("after up:\n got %+v\n want %+v", got, want) + } + var groups int + if err := tx.QueryRow(t.Context(), `SELECT count(*) FROM user_collection_groups`).Scan(&groups); err != nil { + t.Fatal(err) + } + if groups != 0 { + t.Fatalf("collection groups after up = %d, want 0", groups) + } + + // Down rewrites the allow lists the previous code reads from is_shared. + migrationExec(t, tx, adminMigrationSQL(t, personalCollectionLoginSharingMigration, schema, true)) + audience := func(id string) []string { + t.Helper() + rows, err := tx.Query(t.Context(), `SELECT profile_id FROM user_personal_collection_profiles WHERE collection_id = $1 ORDER BY profile_id`, id) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var out []string + for rows.Next() { + var p string + if err := rows.Scan(&p); err != nil { + t.Fatal(err) + } + out = append(out, p) + } + return out + } + for id, wantAudience := range map[string][]string{ + "all": {"p1", "p2", "p3"}, + "stale": {"p1", "p2", "p3"}, + "subset": {"p1"}, + "creator_only": {"p2"}, + "implied": {"p1", "p2", "p3"}, + "private": {"p1"}, + "abs_public": nil, + "solo": {"q1"}, + } { + if got := audience(id); !slices.Equal(got, wantAudience) { + t.Fatalf("allow list of %s after down = %v, want %v", id, got, wantAudience) + } + } + var nativeColumns int + if err := tx.QueryRow(t.Context(), `SELECT count(*) FROM information_schema.columns + WHERE table_schema = $1 AND table_name = 'user_personal_collections' AND column_name = 'native'`, schema).Scan(&nativeColumns); err != nil { + t.Fatal(err) + } + if nativeColumns != 0 { + t.Fatal("native column survived the down migration") + } + + // Up again reaches the same sharing state. + migrationExec(t, tx, up) + got := read() + for id, w := range want { + if got[id].Native != w.Native || got[id].Shared != w.Shared { + t.Fatalf("after down and up, %s = %+v, want native=%t shared=%t", id, got[id], w.Native, w.Shared) + } + } +} diff --git a/migrations/sql/20261003235347_personal_collection_login_sharing.sql b/migrations/sql/20261003235347_personal_collection_login_sharing.sql new file mode 100644 index 0000000000..1868316db8 --- /dev/null +++ b/migrations/sql/20261003235347_personal_collection_login_sharing.sql @@ -0,0 +1,104 @@ +-- +goose Up +-- +goose StatementBegin +-- #1615: a personal collection is either private to the profile that created it +-- or shared with every profile on its login. Visibility is decided at read time +-- from is_shared; the per-profile allow list and the login-wide collection +-- groups stop being read. + +-- Audiobookshelf collections and playlists share this table but never had +-- visibility rows, and native routes must keep ignoring them. Native rows always +-- carry at least their creator's visibility row (written on create since +-- migration 016), so that row marks them. New native rows set the column +-- explicitly; the default keeps every other writer out of native listings. +ALTER TABLE user_personal_collections ADD COLUMN native BOOLEAN NOT NULL DEFAULT FALSE; + +UPDATE user_personal_collections c + SET native = TRUE + WHERE EXISTS ( + SELECT 1 + FROM user_personal_collection_profiles v + WHERE v.user_id = c.user_id + AND v.collection_id = c.id + ); + +-- A collection stays shared only when its allow list covers every current +-- profile of its login (its creator implied). Shared with some profiles, or +-- with nobody but its creator, it becomes private, so no profile gains access +-- its owner withheld. Allow-list rows of deleted profiles do not count. +UPDATE user_personal_collections c + SET is_shared = FALSE + WHERE c.native + AND c.is_shared + AND EXISTS ( + SELECT 1 + FROM user_profiles p + WHERE p.user_id = c.user_id + AND p.id <> c.creator_profile_id + AND NOT EXISTS ( + SELECT 1 + FROM user_personal_collection_profiles v + WHERE v.user_id = c.user_id + AND v.collection_id = c.id + AND v.profile_id = p.id + ) + ); + +-- Each profile gets one flat order of its own collections, numbered from 0: +-- grouped collections first in group order (as the groups were listed: +-- sort_order, name, id), each group in its stored collection order, then the +-- ungrouped collections. +WITH ranked AS ( + SELECT c.user_id, + c.id, + ROW_NUMBER() OVER ( + PARTITION BY c.user_id, c.creator_profile_id + ORDER BY (c.group_id IS NULL), g.sort_order, g.name, g.id, + c.sort_order, c.created_at, c.id + ) - 1 AS pos + FROM user_personal_collections c + LEFT JOIN user_collection_groups g + ON g.user_id = c.user_id + AND g.id = c.group_id + WHERE c.native +) +UPDATE user_personal_collections c + SET sort_order = r.pos, + group_id = NULL + FROM ranked r + WHERE c.user_id = r.user_id + AND c.id = r.id + AND (c.sort_order <> r.pos OR c.group_id IS NOT NULL); + +-- Personal collection groups are gone. The table stays (empty) until the +-- retired sharing and group tables are dropped. +DELETE FROM user_collection_groups; + +CREATE INDEX idx_user_personal_collections_creator_order + ON user_personal_collections (user_id, creator_profile_id, sort_order); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +-- The previous code reads visibility from the allow list alone, so rewrite it +-- from is_shared: the creator, plus every profile of the login when shared. +-- Groups and the previous order are not restored; restore a backup for those. +DELETE FROM user_personal_collection_profiles v + USING user_personal_collections c + WHERE c.user_id = v.user_id + AND c.id = v.collection_id + AND c.native; + +INSERT INTO user_personal_collection_profiles (user_id, collection_id, profile_id) +SELECT c.user_id, c.id, c.creator_profile_id + FROM user_personal_collections c + WHERE c.native +UNION +SELECT c.user_id, c.id, p.id + FROM user_personal_collections c + JOIN user_profiles p ON p.user_id = c.user_id + WHERE c.native + AND c.is_shared; + +DROP INDEX IF EXISTS idx_user_personal_collections_creator_order; +ALTER TABLE user_personal_collections DROP COLUMN IF EXISTS native; +-- +goose StatementEnd diff --git a/migrations/sql/20261005145809_personal_collection_collages.sql b/migrations/sql/20261005145809_personal_collection_collages.sql new file mode 100644 index 0000000000..204690e9d1 --- /dev/null +++ b/migrations/sql/20261005145809_personal_collection_collages.sql @@ -0,0 +1,35 @@ +-- +goose Up +-- A personal collection without an uploaded or imported poster shows a collage +-- of its titles' posters, chosen per viewer as a server collection's is +-- (library_collection_poster_variants): the first titles the viewer can see, +-- and for another profile's shared collection only those its owner can see +-- too. One collection can have several collages, one per distinct set of +-- source posters, named by a hash of those posters. Rows are touched at most +-- daily while in use and deleted with their collection or after going unused. +CREATE TABLE public.user_personal_collection_poster_variants ( + user_id integer NOT NULL, + collection_id text NOT NULL, + variant_key text NOT NULL, + poster_path text NOT NULL, + poster_thumbhash text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT NOW(), + last_used_at timestamptz NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, collection_id, variant_key), + FOREIGN KEY (user_id, collection_id) + REFERENCES public.user_personal_collections (user_id, id) ON DELETE CASCADE +); + +-- Every deleted collage row hands its objects to the artwork revision +-- collector in the same transaction, as server collection collages do: an +-- unused collage retired by the server, and every collage of a deleted +-- collection or account (ON DELETE CASCADE). +CREATE TRIGGER user_personal_collection_poster_variants_queue_deleted +AFTER DELETE ON public.user_personal_collection_poster_variants +FOR EACH ROW EXECUTE FUNCTION public.queue_deleted_collection_collage(); + +-- +goose Down +-- Dropping the table fires no row trigger, so queue its objects first. +SELECT public.queue_collection_poster_objects(poster_path) +FROM public.user_personal_collection_poster_variants; + +DROP TABLE IF EXISTS public.user_personal_collection_poster_variants; diff --git a/migrations/sql/20261005172417_personal_collection_smart_collages.sql b/migrations/sql/20261005172417_personal_collection_smart_collages.sql new file mode 100644 index 0000000000..6c830f7fc4 --- /dev/null +++ b/migrations/sql/20261005172417_personal_collection_smart_collages.sql @@ -0,0 +1,25 @@ +-- +goose Up +-- A smart personal collection's collage comes from its query's first matches, +-- which are too costly to read on every list read. A background refresh reads +-- them and records here which stored collage each viewer access sees, so a +-- list read finds it by key alone. variant_key is NULL when that viewer can +-- see no match with a poster. A row goes with its collection, and with its +-- collage when the server retires that collage as unused. +CREATE TABLE public.user_personal_collection_smart_collages ( + user_id integer NOT NULL, + collection_id text NOT NULL, + -- A hash of the viewer's access filter. + access_key text NOT NULL, + -- A hash of the query definition the collage was chosen for. + definition_key text NOT NULL, + variant_key text, + refreshed_at timestamptz NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, collection_id, access_key), + FOREIGN KEY (user_id, collection_id) + REFERENCES public.user_personal_collections (user_id, id) ON DELETE CASCADE, + FOREIGN KEY (user_id, collection_id, variant_key) + REFERENCES public.user_personal_collection_poster_variants (user_id, collection_id, variant_key) ON DELETE CASCADE +); + +-- +goose Down +DROP TABLE IF EXISTS public.user_personal_collection_smart_collages; diff --git a/scripts/ci/db-pins.txt b/scripts/ci/db-pins.txt index ffd7a622c6..cf1f745832 100644 --- a/scripts/ci/db-pins.txt +++ b/scripts/ci/db-pins.txt @@ -115,6 +115,14 @@ # variants out of retirement. ./internal/catalog TestCollectionCollageReadsKeepActiveVariantsDB +# Personal smart collection collages: a list read serves recorded collages +# with one statement and never runs the collections' queries. +./internal/catalog TestPersonalCollectionCollageListReadsSkipSmartQueriesDB + +# Shared personal collections: a listing resolves each other owner's access +# once, and a profile's own collections resolve nobody. +./internal/api/handlers TestPersonalCollectionOwnerLookupBudgetDB + # Metadata refresh: season and episode writes cost the same number of # statements for 1 and 100 episodes; episode refresh debt the same for 1, 100 # and 1000, with one DELETE per sync. @@ -140,6 +148,10 @@ ./internal/sections TestOverlaySummariesRankingMatchesGo ./internal/sections TestNextUpModeStatementBudgetPostgres +# Server collection rows: a cached row costs one revision read, which shows an +# edit on the next read, and lists what the collection's page lists. +./internal/sections TestLibraryCollectionRailsMatchTheCollectionPageDB + # Settings: one query per batched resolution, whatever its scopes and keys. ./internal/userstore/pgstore TestPostgresResolutionIssuesOneQuery diff --git a/web/.prettierignore b/web/.prettierignore index 84f3869a73..6c52dbecd8 100644 --- a/web/.prettierignore +++ b/web/.prettierignore @@ -1,3 +1,6 @@ dist node_modules *.tsbuildinfo +# Generated by tests; regenerate them rather than reformat. +src/lib/homeRows/__fixtures__/recipeCatalog.v2.json +src/lib/homeRows/payloads.golden.json diff --git a/web/eslint.config.js b/web/eslint.config.js index f7330467b6..6d04da9635 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -52,7 +52,6 @@ export default tseslint.config( "src/hooks/queries/favorites.ts", "src/hooks/queries/watchlist.ts", "src/hooks/queries/ratings.ts", - "src/components/RecipeGallery/BulkApplyDialog.tsx", "src/hooks/queries/autoPlayNext.ts", "src/hooks/queries/libraryPageState.ts", "src/hooks/queries/pluginSettings.ts", @@ -68,7 +67,6 @@ export default tseslint.config( "src/pages/Recommendations.tsx", "src/pages/RecommendationsSection.tsx", "src/components/RecommendationGrid.tsx", - "src/pages/ProfileCustomizeHome.tsx", "src/pages/setup-wizard/steps/ProfileStep.tsx", "src/player/utils/subtitleChoicePersistence.ts", "src/hooks/queries/catalogRead.ts", diff --git a/web/perf-budget.json b/web/perf-budget.json index 341d664066..c85e8a352e 100644 --- a/web/perf-budget.json +++ b/web/perf-budget.json @@ -1,4 +1,4 @@ { - "eagerBrotliBytes": 322026, + "eagerBrotliBytes": 324185, "crossOriginRenderBlocking": 0 } diff --git a/web/src/App.adminRoutes.test.tsx b/web/src/App.adminRoutes.test.tsx new file mode 100644 index 0000000000..0e16c5750a --- /dev/null +++ b/web/src/App.adminRoutes.test.tsx @@ -0,0 +1,77 @@ +import type { ReactNode } from "react"; +import { render, screen } from "@testing-library/react"; +import { Outlet, type createMemoryRouter } from "react-router"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +let initialEntry = "/"; +let appRouter: ReturnType | null = null; + +vi.mock("react-router", async () => { + const actual = await vi.importActual("react-router"); + return { + ...actual, + // App builds a data router from the real history; start it at the entry + // under test instead. + createBrowserRouter: ((routes: Parameters[0]) => { + appRouter = actual.createMemoryRouter(routes, { initialEntries: [initialEntry] }); + return appRouter; + }) as typeof actual.createBrowserRouter, + }; +}); + +// A signed-in admin on a chosen profile, so the admin gate lets the +// navigation through; the session itself is not under test. +vi.mock("@/hooks/useAuth", async () => { + const actual = await vi.importActual("@/hooks/useAuth"); + const auth = { + user: { id: 1, username: "alex", role: "admin" }, + profile: { id: "profile-1", name: "Alex" }, + loading: false, + setupLoading: false, + setupRequired: false, + pendingPasswordChange: false, + isImpersonating: false, + }; + return { + ...actual, + AuthProvider: ({ children }: { children: ReactNode }) => <>{children}, + useAuth: () => auth, + useOptionalAuth: () => auth, + }; +}); +vi.mock("@/hooks/useIsActingAdmin", () => ({ useIsActingAdmin: () => true })); + +vi.mock("@/components/AdminLayout", () => ({ default: () => })); +vi.mock("@/pages/AdminHomeRows", () => ({ default: () =>
Home rows page
})); + +import App from "@/App"; + +describe("App admin Home rows routes", () => { + beforeEach(() => { + appRouter = null; + }); + + it("serves the Home rows page at /admin/home-rows", async () => { + initialEntry = "/admin/home-rows"; + + render(); + + expect(await screen.findByText("Home rows page")).toBeInTheDocument(); + expect(appRouter?.state.location.pathname).toBe("/admin/home-rows"); + }); + + it("sends the old /admin/sections links to Home rows, keeping the query", async () => { + initialEntry = "/admin/sections?page=page-2#rows"; + + render(); + + expect(await screen.findByText("Home rows page")).toBeInTheDocument(); + expect(appRouter?.state.location).toMatchObject({ + pathname: "/admin/home-rows", + search: "?page=page-2", + hash: "#rows", + }); + // Replaced, not pushed: Back must not return to the old URL and bounce again. + expect(appRouter?.state.historyAction).toBe("REPLACE"); + }); +}); diff --git a/web/src/App.routes.test.tsx b/web/src/App.routes.test.tsx new file mode 100644 index 0000000000..cb78bdad84 --- /dev/null +++ b/web/src/App.routes.test.tsx @@ -0,0 +1,68 @@ +import type { ReactNode } from "react"; +import { render, screen } from "@testing-library/react"; +import { Outlet, type createMemoryRouter } from "react-router"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +let initialEntry = "/"; +let appRouter: ReturnType | null = null; + +vi.mock("react-router", async () => { + const actual = await vi.importActual("react-router"); + return { + ...actual, + // App builds a data router from the real history; start it at the entry + // under test instead. + createBrowserRouter: ((routes: Parameters[0]) => { + appRouter = actual.createMemoryRouter(routes, { initialEntries: [initialEntry] }); + return appRouter; + }) as typeof actual.createBrowserRouter, + }; +}); + +// A signed-in account with a selected profile, so the route gates let the +// navigation through; the session itself is not under test. +vi.mock("@/hooks/useAuth", async () => { + const actual = await vi.importActual("@/hooks/useAuth"); + const auth = { + user: { id: 1, username: "alex", role: "user" }, + profile: { id: "profile-1", name: "Alex" }, + loading: false, + setupLoading: false, + setupRequired: false, + pendingPasswordChange: false, + isImpersonating: false, + }; + return { + ...actual, + AuthProvider: ({ children }: { children: ReactNode }) => <>{children}, + useAuth: () => auth, + useOptionalAuth: () => auth, + }; +}); + +vi.mock("@/components/Layout", () => ({ + default: ({ children }: { children: ReactNode }) => <>{children}, +})); +vi.mock("@/pages/SettingsLayout", () => ({ default: () => })); +vi.mock("@/pages/settings/HomeScreenSettings", () => ({ + default: () =>
Home screen settings
, +})); + +import App from "@/App"; + +describe("App routes", () => { + beforeEach(() => { + appRouter = null; + }); + + it("redirects the retired profile home editor to Settings > Home Screen", async () => { + initialEntry = "/profile/customize-home"; + + render(); + + expect(await screen.findByText("Home screen settings")).toBeInTheDocument(); + expect(appRouter?.state.location.pathname).toBe("/settings/home-screen"); + // Replaced, not pushed: Back must not return to the old URL and bounce again. + expect(appRouter?.state.historyAction).toBe("REPLACE"); + }); +}); diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 8a544b9c80..2139ca0980 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -4,6 +4,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Profile, User } from "@/api/types"; import type { useAuth } from "@/hooks/useAuth"; +import { profilePeekKey } from "@/lib/homeRows/peek"; +import type { HomeRow } from "@/lib/homeRows/types"; type AuthState = ReturnType; @@ -47,7 +49,7 @@ function renderManager() { , ); }; - return { clear, signIn }; + return { queryClient, clear, signIn }; } describe("QueryCacheManager", () => { @@ -74,4 +76,16 @@ describe("QueryCacheManager", () => { expect(clear).not.toHaveBeenCalled(); }); + + it("drops the last profile's Home rows poster peeks on a profile switch", () => { + const { queryClient, signIn } = renderManager(); + signIn({ user: makeUser(1), profile: makeProfile("p-1") }); + const row = { id: "r1", sectionType: "continue_watching", config: {}, itemLimit: 20 }; + const key = profilePeekKey({ kind: "home" }, row as HomeRow); + queryClient.setQueryData(key, [{ id: "movie-1", title: "Heat" }]); + + signIn({ user: makeUser(1), profile: makeProfile("p-2") }); + + expect(queryClient.getQueryData(key)).toBeUndefined(); + }); }); diff --git a/web/src/App.tsx b/web/src/App.tsx index e180b87326..68377eeb52 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -61,6 +61,7 @@ import { buildUserCollectionCatalogHref, } from "@/pages/catalogSearchParams"; import { buildLegacyAutoscanRedirectTarget } from "@/pages/autoscanSearchParams"; +import LegacyAdminSectionsRedirect from "@/pages/LegacyAdminSectionsRedirect"; import LegacyRequestDetailRedirect from "@/pages/LegacyRequestDetailRedirect"; import { buildLegacyWebhookSyncRedirectTarget } from "@/lib/webhookSync"; import { guardRedirectTarget } from "@/lib/authRedirect"; @@ -93,7 +94,7 @@ const ItemDetail = lazy(importItemDetail); const EbookReader = lazy(() => import("@/pages/EbookReader")); const PersonDetail = lazy(importPersonDetail); const Collections = lazy(importCollections); -const CollectionEditor = lazy(() => import("@/pages/CollectionEditor")); +const CollectionEditorPage = lazy(() => import("@/pages/CollectionEditorPage")); const Notifications = lazy(() => import("@/pages/Notifications")); const DeviceSettings = lazy(() => import("@/pages/settings/DeviceSettings")); const PlaybackSettings = lazy(() => import("@/pages/settings/PlaybackSettings")); @@ -113,9 +114,8 @@ const AdminDevices = lazy(() => import("@/pages/AdminDevices")); const AdminLibraries = lazy(() => import("@/pages/AdminLibraries")); const AdminSettingsLayout = lazy(() => import("@/pages/admin-settings/AdminSettingsLayout")); const AdminNodes = lazy(() => import("@/pages/AdminNodes")); -const AdminSections = lazy(() => import("@/pages/AdminSections")); +const AdminHomeRows = lazy(() => import("@/pages/AdminHomeRows")); const AdminCollections = lazy(() => import("@/pages/AdminCollections")); -const AdminCollectionEditor = lazy(() => import("@/pages/AdminCollectionEditor")); const AdminPlaybackHistory = lazy(() => import("@/pages/AdminPlaybackHistory")); const AdminMarkerHistory = lazy(() => import("@/pages/AdminMarkerHistory")); const AdminMaintenance = lazy(() => import("@/pages/AdminMaintenance")); @@ -159,7 +159,6 @@ const WatchPartyHub = lazy(() => import("@/pages/watchtogether/WatchPartyHub")); const WatchPartyInvite = lazy(() => import("@/pages/watchtogether/WatchPartyInvite")); const WatchTogetherRoomPage = lazy(() => import("@/pages/watchtogether/WatchTogetherRoomPage")); const WatchRoute = lazy(() => import("@/pages/WatchRoute")); -const ProfileCustomizeHome = lazy(() => import("@/pages/ProfileCustomizeHome")); /** * Routes a browsing session reaches within the first few interactions. Home @@ -344,6 +343,8 @@ export function QueryCacheManager() { qc.removeQueries({ queryKey: ["libraryPlaybackPreferences"] }); qc.removeQueries({ queryKey: ["progress"] }); qc.removeQueries({ queryKey: ["sections"] }); + // Home rows poster peeks (lib/homeRows/peek) show rows as the profile sees them. + qc.removeQueries({ queryKey: ["home-row-peek"] }); qc.removeQueries({ queryKey: ["calendar"] }); qc.removeQueries({ queryKey: ["requests"] }); qc.removeQueries({ queryKey: ["notifications"] }); @@ -521,8 +522,11 @@ function AppRoutes() { } /> } /> } /> - } /> - } /> + {/* One page from /new to /:id/edit, so Create keeps the editor mounted. */} + }> + + + } /> {/* Autoscan is a tab on Libraries now; keep old links working. */} } /> @@ -536,7 +540,8 @@ function AppRoutes() { } /> } /> } /> - } /> + } /> + } /> } /> } /> } /> @@ -659,8 +664,10 @@ function AppRoutes() { element={} /> } /> - } /> - } /> + }> + + + } @@ -728,9 +735,10 @@ function AppRoutes() { /> } /> } /> + {/* Retired second profile Home editor; keep old links working. */} } + element={} /> } /> diff --git a/web/src/api/adminCollectionRows.ts b/web/src/api/adminCollectionRows.ts new file mode 100644 index 0000000000..b14365e426 --- /dev/null +++ b/web/src/api/adminCollectionRows.ts @@ -0,0 +1,97 @@ +import { + deleteAdminSection, + fetchAdminSectionOrderSnapshot, + fetchAdminSectionSnapshot, +} from "@/api/adminSections"; +import type { components } from "@/api/v2/schema"; +import { v2, V2ProblemError } from "@/api/v2/request"; +import type { CollectionRow } from "@/lib/collections/rows"; +import type { PageRef } from "@/lib/homeRows/types"; + +type AdminCollectionSection = components["schemas"]["AdminCollectionSection"]; + +function rowPage(value: AdminCollectionSection): PageRef { + return value.scope === "library" && value.library_id !== null + ? { kind: "library", libraryId: Number(value.library_id) } + : { kind: "home" }; +} + +function pageKey(page: PageRef) { + return page.kind === "home" ? "home" : String(page.libraryId); +} + +/** + * The administrator Home and library page rows that show a server collection. + * Stored positions skip numbers once a row is deleted, so each row's place is + * its rank in its page's order, read alongside. + */ +export async function fetchAdminCollectionRows( + id: string, + signal?: AbortSignal, +): Promise { + const { items } = await v2("GET /api/v2/admin/collections/{id}/sections", { + path: { id }, + signal, + }); + const pages = new Map(items.map((item) => [pageKey(rowPage(item)), rowPage(item)])); + const orders = new Map( + await Promise.all( + [...pages].map(async ([key, page]) => { + const { ordered_ids } = await fetchAdminSectionOrderSnapshot( + page.kind, + page.kind === "library" ? page.libraryId : undefined, + signal, + ); + return [key, ordered_ids] as const; + }), + ), + ); + return items.map((item) => { + const page = rowPage(item); + return { + id: item.id, + page, + title: item.title, + enabled: item.enabled, + ...rowPlace(item, orders.get(pageKey(page)) ?? []), + }; + }); +} + +/** The row's place on its page, counting from 1, and how many rows the page has. */ +function rowPlace(item: AdminCollectionSection, order: readonly string[]) { + const rank = order.indexOf(item.id); + if (rank >= 0) return { position: rank + 1, pageRowCount: order.length }; + // Deleted since the list was read: the server's own numbers are all there is. + const pageRowCount = Math.max(item.page_row_count, 1); + return { position: Math.min(item.position + 1, pageRowCount), pageRowCount }; +} + +function isGone(error: unknown) { + return error instanceof V2ProblemError && error.status === 404; +} + +/** + * Deletes the `rows` that show collection `collectionId`, one at a time, each + * with the ETag of a fresh read. `rows` can be stale: a row that is already + * gone, or that now shows another collection, is left alone and counts as + * done (the collection's own delete still refuses if a row uses it). The + * first failure stops the run: `remaining` holds that row and every row after + * it, with the error. + */ +export async function deleteAdminCollectionRows( + collectionId: string, + rows: readonly CollectionRow[], +): Promise<{ remaining: CollectionRow[]; error?: unknown }> { + for (const [index, row] of rows.entries()) { + try { + const { section, etag } = await fetchAdminSectionSnapshot(row.id); + if (section.config?.library_collection_id !== collectionId) continue; + await deleteAdminSection({ id: row.id, etag }); + } catch (error) { + if (isGone(error)) continue; + return { remaining: rows.slice(index), error }; + } + } + return { remaining: [] }; +} diff --git a/web/src/api/adminCollections.test.ts b/web/src/api/adminCollections.test.ts index c33cddf60e..0b50333e9a 100644 --- a/web/src/api/adminCollections.test.ts +++ b/web/src/api/adminCollections.test.ts @@ -27,6 +27,7 @@ const collection: components["schemas"]["AdminCollection"] = { sort_order: 0, featured: false, poster_url: "", + poster_is_collage: false, backdrop_url: "", source_url: "", query_definition: {}, @@ -117,7 +118,7 @@ describe("admin collection adapter", () => { const snapshots = await prepareAdminCollectionDeletes(["1", "2", "3", "4", "5", "1"]); expect(maximum).toBe(4); expect(snapshots).toHaveLength(5); - expect(snapshots[4]).toEqual({ id: "5", etag: '"5"' }); + expect(snapshots[4]).toMatchObject({ id: "5", etag: '"5"', collection: { id: "5" } }); expect( request.mock.calls.every(([operation]) => operation === "GET /api/v2/admin/collections/{id}"), ).toBe(true); diff --git a/web/src/api/adminCollections.ts b/web/src/api/adminCollections.ts index 7e847017c8..93a0d09fef 100644 --- a/web/src/api/adminCollections.ts +++ b/web/src/api/adminCollections.ts @@ -7,7 +7,7 @@ import type { } from "@/api/types"; import { normalizeQueryDefinition } from "@/api/types"; import { v2, V2ProblemError } from "@/api/v2/request"; -import { requiredETag } from "@/api/personalCollections"; +import { withETag } from "@/api/v2/etag"; type AdminCollection = components["schemas"]["AdminCollection"]; export function adminCollectionFromV2(value: AdminCollection): LibraryCollection { @@ -125,66 +125,50 @@ export function adminMutationMessage(error: unknown, fallback: string) { : fallback; } export async function fetchAdminCollectionSnapshot(id: string) { - let etag: string | null = null; - const collection = await v2("GET /api/v2/admin/collections/{id}", { - path: { id }, - onResponse: (response) => { - etag = response.headers.get("ETag"); - }, - }); - return { collection: adminCollectionFromV2(collection), etag: requiredETag(etag) }; + const { body, etag } = await withETag("GET /api/v2/admin/collections/{id}", { path: { id } }); + return { collection: adminCollectionFromV2(body), etag }; } export async function fetchAdminGroupSnapshot(id: string) { - let etag: string | null = null; - const group = await v2("GET /api/v2/admin/collection-groups/{id}", { + const { body, etag } = await withETag("GET /api/v2/admin/collection-groups/{id}", { path: { id }, - onResponse: (response) => { - etag = response.headers.get("ETag"); - }, }); - return { group: adminGroupFromV2(group), etag: requiredETag(etag) }; + return { group: adminGroupFromV2(body), etag }; } export async function fetchAdminGroupOrderSnapshot(libraryId: number) { - let etag: string | null = null; - const body = await v2("GET /api/v2/admin/libraries/{library_id}/collection-groups/order", { - path: { library_id: String(libraryId) }, - onResponse: (response) => { - etag = response.headers.get("ETag"); - }, - }); - return { ...body, etag: requiredETag(etag) }; + const { body, etag } = await withETag( + "GET /api/v2/admin/libraries/{library_id}/collection-groups/order", + { path: { library_id: String(libraryId) } }, + ); + return { ...body, etag }; } export async function fetchAdminGroupCollectionOrderSnapshot(groupID: string, libraryId: number) { - let etag: string | null = null; - const body = await v2("GET /api/v2/admin/collection-groups/{group_id}/collections/order", { - path: { group_id: groupID }, - query: { library_id: String(libraryId) }, - onResponse: (response) => { - etag = response.headers.get("ETag"); - }, - }); - return { ...body, etag: requiredETag(etag) }; + const { body, etag } = await withETag( + "GET /api/v2/admin/collection-groups/{group_id}/collections/order", + { path: { group_id: groupID }, query: { library_id: String(libraryId) } }, + ); + return { ...body, etag }; } export async function fetchAdminItemOrderSnapshot(id: string) { - let etag: string | null = null; - const body = await v2("GET /api/v2/admin/collections/{id}/items/order", { + const { body, etag } = await withETag("GET /api/v2/admin/collections/{id}/items/order", { path: { id }, - onResponse: (response) => { - etag = response.headers.get("ETag"); - }, }); - return { ...body, etag: requiredETag(etag) }; + return { ...body, etag }; } /** Read before opening the confirmation, with at most four requests in flight. */ +export type AdminCollectionDeleteSnapshot = { + id: string; + etag: string; + collection: LibraryCollection; +}; export async function prepareAdminCollectionDeletes(ids: string[]) { - const snapshots: { id: string; etag: string }[] = []; + const snapshots: AdminCollectionDeleteSnapshot[] = []; const unique = [...new Set(ids)]; for (let offset = 0; offset < unique.length; offset += 4) { snapshots.push( ...(await Promise.all( unique .slice(offset, offset + 4) - .map(async (id) => ({ id, etag: (await fetchAdminCollectionSnapshot(id)).etag })), + .map(async (id) => ({ id, ...(await fetchAdminCollectionSnapshot(id)) })), )), ); } diff --git a/web/src/api/personalCollections.test.ts b/web/src/api/personalCollections.test.ts index 9627fbcdc6..82951fa04f 100644 --- a/web/src/api/personalCollections.test.ts +++ b/web/src/api/personalCollections.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import type { components } from "@/api/v2/schema"; import { + collectionsFromV2, collectionUpdateToV2, discoveryFromV2, importBodyToV2, @@ -19,13 +20,13 @@ const collection: components["schemas"]["PersonalCollection"] = { description: "", collection_type: "manual", is_shared: false, - allowed_profile_ids: ["owner"], query_definition: {}, sort_config: {}, sort_order: 0, group_id: null, source_url: "", sync_schedule: "", + sync_cadence: "", next_sync_at: null, last_sync_at: null, last_sync_status: "", @@ -34,14 +35,28 @@ const collection: components["schemas"]["PersonalCollection"] = { include_in_server_collections: false, poster_url: "", poster_thumbhash: "", + poster_is_collage: false, created_at: "2026-09-05T00:00:00.000Z", updated_at: "2026-09-05T00:00:00.000Z", }; describe("personal collection v2 adapter", () => { - it("preserves group removal and converts library IDs without clearing omitted settings", () => { - expect(collectionUpdateToV2({ group_id: null, library_ids: [7], max_items: 0 })).toEqual({ - group_id: null, + it("lists own and shared collections without personal collection groups", () => { + const shared = { ...collection, id: "theirs", creator_profile_id: "parent", is_shared: true }; + const result = collectionsFromV2( + v2Fixture<"GET /api/v2/collections">({ items: [collection, shared], groups: [] }), + ); + expect(result).toEqual({ + collections: [ + expect.objectContaining({ id: "saved", creator_profile_id: "owner" }), + expect.objectContaining({ id: "theirs", creator_profile_id: "parent", is_shared: true }), + ], + }); + }); + + it("sends the sharing switch and converts library IDs without clearing omitted settings", () => { + expect(collectionUpdateToV2({ is_shared: true, library_ids: [7], max_items: 0 })).toEqual({ + is_shared: true, library_ids: ["7"], max_items: 0, }); @@ -90,3 +105,53 @@ describe("personal collection v2 adapter", () => { expect(request).toHaveBeenCalledTimes(1); }); }); + +describe("saving a staged poster removal", () => { + const withPoster = { ...collection, poster_url: "https://images.example/p.webp" }; + + it("deletes the poster and returns the collection without it", async () => { + request.mockReset().mockResolvedValueOnce(undefined); + const result = await saveCollectionPoster(withPoster, null, undefined, true); + expect(request.mock.calls).toEqual([ + [ + "DELETE /api/v2/collections/{id}/image", + { path: { id: "saved" }, query: { type: "poster" } }, + ], + ]); + expect(result.collection.poster_url).toBe(""); + expect(result.posterError).toBeUndefined(); + }); + + it("uploads a replacement file instead of deleting, so the new poster survives", async () => { + request.mockReset().mockResolvedValueOnce(withPoster); + const poster = new File(["image"], "poster.png", { type: "image/png" }); + await saveCollectionPoster(withPoster, poster, undefined, true); + expect(request.mock.calls).toEqual([ + ["PUT /api/v2/collections/{id}/poster", { path: { id: "saved" }, form: { poster } }], + ]); + }); + + it("uploads a replacement URL instead of deleting", async () => { + request.mockReset().mockResolvedValueOnce(withPoster); + await saveCollectionPoster(withPoster, null, "https://images.example/new.png", true); + expect(request.mock.calls).toEqual([ + [ + "PUT /api/v2/collections/{id}/poster", + { path: { id: "saved" }, form: { source_url: "https://images.example/new.png" } }, + ], + ]); + }); + + it("keeps the saved collection and reports a failed removal", async () => { + request.mockReset().mockRejectedValueOnce(new Error("Storage unavailable")); + const result = await saveCollectionPoster(withPoster, null, undefined, true); + expect(result.collection.poster_url).toBe("https://images.example/p.webp"); + expect(result.posterError).toBe("Storage unavailable"); + }); + + it("sends nothing when no poster change is staged", async () => { + request.mockReset(); + await saveCollectionPoster(withPoster); + expect(request).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/api/personalCollections.ts b/web/src/api/personalCollections.ts index 051ed9a0ad..f3fc2dd1a9 100644 --- a/web/src/api/personalCollections.ts +++ b/web/src/api/personalCollections.ts @@ -1,6 +1,5 @@ import type { Collection, - CollectionGroup, CollectionsListResponse, CreateCollectionRequest, UpdateCollectionRequest, @@ -8,11 +7,11 @@ import type { CollectionPreviewResponse, MDBListDiscoveryResponse, UserImportSharedFields, - ImportUserCollectionResponse, UserCollectionSyncResult, ServerCollectionsResponse, } from "@/api/types"; import { normalizeQueryDefinition } from "@/api/types"; +import { withETag } from "@/api/v2/etag"; import { v2, type V2Body, type V2Result } from "@/api/v2/request"; import type { components } from "@/api/v2/schema"; @@ -38,13 +37,7 @@ export function collectionFromV2(value: CollectionV2): Collection { export function collectionsFromV2( value: V2Result<"GET /api/v2/collections">, ): CollectionsListResponse { - return { - collections: value.items.map(collectionFromV2), - groups: value.groups.map((group) => ({ - ...group, - default_sort_mode: group.default_sort_mode as CollectionGroup["default_sort_mode"], - })), - }; + return { collections: value.items.map(collectionFromV2) }; } export function collectionCreateToV2( @@ -60,16 +53,31 @@ export function collectionUpdateToV2( return { ...fields, library_ids: body.library_ids?.map(String) }; } -/** Saving succeeded even when a later poster upload fails. Return the saved - * resource so the dialog closes and retries cannot create duplicate collections. */ +/** Saving succeeded even when a later poster change fails. Return the saved + * resource so the dialog closes and retries cannot create duplicate collections. + * + * A removal staged in the editor is sent here, after the collection saved. A + * new file or URL wins over it: the upload replaces the poster and clears the + * old image itself, so a DELETE would only remove the new one. */ export async function saveCollectionPoster( collection: CollectionV2, poster?: File | null, sourceURL?: string, + removePoster = false, ) { - if (!poster && !sourceURL) + if (!poster && !sourceURL && !removePoster) return { collection: collectionFromV2(collection), posterError: undefined }; try { + if (!poster && !sourceURL) { + await v2("DELETE /api/v2/collections/{id}/image", { + path: { id: collection.id }, + query: { type: "poster" }, + }); + return { + collection: collectionFromV2({ ...collection, poster_url: "", poster_thumbhash: "" }), + posterError: undefined, + }; + } const updated = await v2("PUT /api/v2/collections/{id}/poster", { path: { id: collection.id }, form: poster ? { poster } : { source_url: sourceURL! }, @@ -78,7 +86,7 @@ export async function saveCollectionPoster( } catch (error) { return { collection: collectionFromV2(collection), - posterError: error instanceof Error ? error.message : "Poster upload failed", + posterError: error instanceof Error ? error.message : "Poster update failed", }; } } @@ -114,14 +122,6 @@ export function syncFromV2( ): UserCollectionSyncResult { return { ...value, status: value.status as UserCollectionSyncResult["status"] }; } -export function importFromV2( - value: components["schemas"]["CollectionImportResult"], -): ImportUserCollectionResponse { - return { - collection: collectionFromV2(value.collection), - sync: value.sync ? syncFromV2(value.sync) : undefined, - }; -} export function serverCollectionsFromV2( value: V2Result<"GET /api/v2/collections/server">, ): ServerCollectionsResponse["libraries"] { @@ -136,58 +136,19 @@ export interface CollectionEditSnapshot { collection: Collection; etag: string; } -export function requiredETag(etag: string | undefined | null): string { - if (!etag || etag === "*") - throw new Error("Reload this collection before editing; its version is unavailable."); - return etag; -} export async function fetchCollectionEditSnapshot(id: string): Promise { - let etag: string | null = null; - const collection = await v2("GET /api/v2/collections/{id}", { - path: { id }, - onResponse: (response) => { - etag = response.headers.get("ETag"); - }, - }); - return { collection: collectionFromV2(collection), etag: requiredETag(etag) }; + const { body, etag } = await withETag("GET /api/v2/collections/{id}", { path: { id } }); + return { collection: collectionFromV2(body), etag }; } -export async function fetchCollectionOrderSnapshot(groupID: string | null) { - let etag: string | null = null; - const body = await v2("GET /api/v2/collections/order", { - query: groupID ? { group_id: groupID } : {}, - onResponse: (response) => { - etag = response.headers.get("ETag"); - }, - }); - return { ...body, etag: requiredETag(etag) }; -} -export async function fetchGroupOrderSnapshot() { - let etag: string | null = null; - const body = await v2("GET /api/v2/collections/groups/order", { - onResponse: (response) => { - etag = response.headers.get("ETag"); - }, - }); - return { ...body, etag: requiredETag(etag) }; -} -export async function fetchGroupSnapshot(id: string) { - let etag: string | null = null; - const group = await v2("GET /api/v2/collections/groups/{id}", { - path: { id }, - onResponse: (response) => { - etag = response.headers.get("ETag"); - }, - }); - return { group, etag: requiredETag(etag) }; +/** The acting profile's own collections in its order, with the order's validator. */ +export async function fetchCollectionOrderSnapshot() { + const { body, etag } = await withETag("GET /api/v2/collections/order"); + return { ordered_ids: body.ordered_ids, etag }; } export async function fetchItemOrderSnapshot(id: string) { - let etag: string | null = null; - const body = await v2("GET /api/v2/collections/{id}/items/order", { + const { body, etag } = await withETag("GET /api/v2/collections/{id}/items/order", { path: { id }, - onResponse: (response) => { - etag = response.headers.get("ETag"); - }, }); - return { ...body, etag: requiredETag(etag) }; + return { ...body, etag }; } diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 84b7ad182e..2f1d9e95db 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -1330,15 +1330,18 @@ export interface Collection { name: string; description?: string; collection_type: UserCollectionType; + /** Every profile on the login sees the collection read-only; otherwise only its creator. */ is_shared: boolean; - allowed_profile_ids: string[]; query_definition: QueryDefinition; sort_config: Record; + /** Position in the creator's own order of collections. */ sort_order: number; group_id?: string | null; source_url?: string; source_config?: Record; sync_schedule?: string; + /** The cadence `sync_schedule` names; "custom" for a schedule no name produces. */ + sync_cadence?: UserCollectionSyncCadence; next_sync_at?: string; last_sync_at?: string; last_sync_status?: UserCollectionSyncStatus; @@ -1349,6 +1352,10 @@ export interface Collection { include_in_server_collections?: boolean; poster_url?: string; poster_thumbhash?: string; + /** `poster_url` is the collage the server made of its titles, not an uploaded or imported image. */ + poster_is_collage?: boolean; + /** Whether it holds the list's `contains_item` title; only on the profile's own manual collections. */ + contains?: boolean; created_at: string; updated_at: string; } @@ -1373,17 +1380,9 @@ export interface CollectionItem { added_at: string; } -export interface CollectionGroup { - id: string; - name: string; - slug: string; - default_sort_mode: GroupSortMode; - sort_order: number; -} - export interface CollectionsListResponse { + /** The profile's own collections in its order, then other profiles' shared ones. */ collections: Collection[]; - groups: CollectionGroup[]; } export interface CollectionCapabilitiesResponse { @@ -1478,11 +1477,6 @@ export interface QueryDefinitionInput { limit?: number; } -export interface SmartCollectionAccess { - is_shared: boolean; - allowed_profile_ids: string[]; -} - export interface CollectionPreviewRequest { query_definition: QueryDefinition; limit?: number; @@ -1501,9 +1495,10 @@ export interface CollectionPreviewResponse { export interface CreateCollectionRequest { name: string; + /** Accepted when collection capabilities report `create_description`. */ + description?: string; collection_type?: "manual" | "smart"; is_shared?: boolean; - allowed_profile_ids?: string[]; query_definition?: QueryDefinition; sort_config?: Record; /** Filter-only QueryDefinition fragment; omit for no display filter. */ @@ -1516,7 +1511,6 @@ export interface UpdateCollectionRequest { name?: string; description?: string; is_shared?: boolean; - allowed_profile_ids?: string[]; query_definition?: QueryDefinition; sort_config?: Record; source_url?: string; @@ -1528,6 +1522,8 @@ export interface UpdateCollectionRequest { include_in_server_collections?: boolean; poster_source_url?: string; group_id?: string | null; + /** A synced list's cadence; "" stops scheduled syncs. */ + sync_schedule?: UserCollectionSyncSchedule; } export interface LibraryCollection { @@ -1546,6 +1542,8 @@ export interface LibraryCollection { backdrop_url: string; poster_thumbhash?: string; backdrop_thumbhash?: string; + /** `poster_url` is the collage the server made of its members, not an uploaded or template image. */ + poster_is_collage?: boolean; source_url: string; query_definition: QueryDefinition; sort_config: Record; @@ -1559,6 +1557,10 @@ export interface LibraryCollection { sync_schedule?: string; next_sync_at?: string; item_count: number; + /** Admin list only: turned-on Home rows that show it. */ + home_row_count?: number; + /** Admin list only: Home and library page rows that show it, turned-off ones included. */ + row_count?: number; created_at: string; updated_at: string; } @@ -1764,6 +1766,7 @@ export interface ImportTraktCollectionResponse { // concerns). sync_schedule is restricted to a fixed set so we can guarantee // the >=24h minimum interval without parsing user-supplied cron. export type UserCollectionSyncSchedule = "" | "daily" | "weekly" | "monthly"; +export type UserCollectionSyncCadence = UserCollectionSyncSchedule | "custom"; export interface UserImportSharedFields { title: string; @@ -1780,10 +1783,6 @@ export interface UserImportSharedFields { sort_config?: CollectionSortConfig; } -export interface ImportUserMDBListCollectionRequest extends UserImportSharedFields { - url: string; -} - export interface MDBListListSummary { id: number; user_id: number; @@ -1804,17 +1803,6 @@ export interface MDBListDiscoveryResponse { lists: MDBListListSummary[]; } -export interface ImportUserTMDBCollectionRequest extends UserImportSharedFields { - preset: ImportTMDBCollectionRequest["preset"]; - media_type: ImportTMDBCollectionRequest["media_type"]; - time_window?: ImportTMDBCollectionRequest["time_window"]; -} - -export interface ImportUserTMDBListCollectionRequest extends UserImportSharedFields { - /** A public TMDB list page URL or its numeric ID. */ - url: string; -} - // A completed sync always has a non-empty status; the empty-string variant in // UserCollectionSyncStatus only appears on un-synced rows. export type UserCollectionSyncResultStatus = Exclude; @@ -1828,11 +1816,6 @@ export interface UserCollectionSyncResult { completed_at: string; } -export interface ImportUserCollectionResponse { - collection: Collection; - sync?: UserCollectionSyncResult; -} - // Media Requests export type RequestMediaType = "movie" | "series"; export type RequestSearchMediaType = RequestMediaType | "all"; @@ -4616,7 +4599,9 @@ export function queryDefinitionFromSectionConfig( ? "ebook" : config.media_scope === "manga" || config.filter_type === "manga" ? "manga" - : undefined; + : config.media_scope === "video" + ? "video" + : undefined; const legacySortField = typeof config.sort === "string" ? config.sort : undefined; const legacySortOrder = typeof config.order === "string" ? config.order : undefined; @@ -4664,6 +4649,8 @@ export interface SettingsSectionEntry { id: string; section_type: string; title: string; + /** The admin row's own title; empty for a profile-built row. Absent on entries built locally. */ + default_title?: string; featured: boolean; item_limit: number; hidden: boolean; diff --git a/web/src/api/v2/etag.test.ts b/web/src/api/v2/etag.test.ts new file mode 100644 index 0000000000..dd77260e82 --- /dev/null +++ b/web/src/api/v2/etag.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { requiredETag, withETag } from "./etag"; + +const request = vi.hoisted(() => vi.fn()); +vi.mock("@/api/v2/request", async () => ({ + ...(await vi.importActual("@/api/v2/request")), + v2: request, +})); + +function answer(body: unknown, etag?: string) { + request.mockImplementationOnce(async (_operation, options) => { + options.onResponse(new Response(null, { headers: etag ? { ETag: etag } : {} })); + return body; + }); +} + +beforeEach(() => request.mockReset()); + +describe("requiredETag", () => { + it("returns a usable validator unchanged", () => { + expect(requiredETag('W/"7"')).toBe('W/"7"'); + }); + it.each([undefined, null, "", "*"])("refuses %j", (etag) => { + expect(() => requiredETag(etag)).toThrow("version is unavailable"); + }); +}); + +describe("withETag", () => { + it("returns the body with the validator the response carried", async () => { + answer({ ordered_ids: ["a"] }, '"order-3"'); + await expect(withETag("GET /api/v2/collections/order")).resolves.toEqual({ + body: { ordered_ids: ["a"] }, + etag: '"order-3"', + }); + expect(request).toHaveBeenCalledWith( + "GET /api/v2/collections/order", + expect.objectContaining({ onResponse: expect.any(Function) }), + ); + }); + + it("throws when the response has no ETag", async () => { + answer({ id: "c1" }); + await expect(withETag("GET /api/v2/collections/{id}", { path: { id: "c1" } })).rejects.toThrow( + "version is unavailable", + ); + }); + + it("passes the request options through and still calls the caller's onResponse", async () => { + const seen = vi.fn(); + answer({ ordered_ids: [] }, '"x"'); + await withETag("GET /api/v2/admin/collection-groups/{group_id}/collections/order", { + path: { group_id: "g1" }, + query: { library_id: "4" }, + onResponse: seen, + }); + expect(request).toHaveBeenCalledWith( + "GET /api/v2/admin/collection-groups/{group_id}/collections/order", + expect.objectContaining({ path: { group_id: "g1" }, query: { library_id: "4" } }), + ); + expect(seen).toHaveBeenCalledOnce(); + }); +}); diff --git a/web/src/api/v2/etag.ts b/web/src/api/v2/etag.ts new file mode 100644 index 0000000000..7719206645 --- /dev/null +++ b/web/src/api/v2/etag.ts @@ -0,0 +1,35 @@ +/** + * Reads that hand back the validator a later guarded write must send as + * If-Match. Components never read response headers; they keep the returned + * `etag` beside the body it describes. + */ +import { v2, type V2OperationKey, type V2RequestArgs, type V2Result } from "./request"; + +/** The validator to send as If-Match. Throws when a guarded read had none to give. */ +export function requiredETag(etag: string | undefined | null): string { + if (!etag || etag === "*") + throw new Error("Reload this collection before editing; its version is unavailable."); + return etag; +} + +/** One v2 request that returns its decoded body and its ETag, or throws when the ETag is missing. */ +export async function withETag( + key: K, + ...args: V2RequestArgs +): Promise<{ body: V2Result; etag: string }> { + const options = args[0]; + let etag: string | null = null; + const body = await v2( + key, + ...([ + { + ...options, + onResponse: (response: Response) => { + etag = response.headers.get("ETag"); + options?.onResponse?.(response); + }, + }, + ] as V2RequestArgs), + ); + return { body, etag: requiredETag(etag) }; +} diff --git a/web/src/api/v2/operations.ts b/web/src/api/v2/operations.ts index eb497dace5..11d126312d 100644 --- a/web/src/api/v2/operations.ts +++ b/web/src/api/v2/operations.ts @@ -122,6 +122,7 @@ export const v2Operations = { "GET /api/v2/admin/collections/{id}": "getAdminCollection", "GET /api/v2/admin/collections/{id}/items": "getAdminCollectionItems", "GET /api/v2/admin/collections/{id}/items/order": "getAdminCollectionItemsOrder", + "GET /api/v2/admin/collections/{id}/sections": "listAdminCollectionSections", "GET /api/v2/admin/dashboard/capabilities": "getAdminDashboardCapabilities", "GET /api/v2/admin/dashboard/layout": "getAdminDashboardLayout", "GET /api/v2/admin/devices": "listAdminDevices", @@ -220,7 +221,6 @@ export const v2Operations = { "GET /api/v2/admin/settings": "getAdminStoredSettings", "GET /api/v2/admin/settings/effective": "getAdminEffectiveSettings", "GET /api/v2/admin/settings/restart-keys": "getAdminRestartKeys", - "GET /api/v2/admin/settings/sections": "getAdminSectionSettings", "GET /api/v2/admin/settings/sensitive-status": "getAdminSensitiveSettingsStatus", "GET /api/v2/admin/settings/{key}": "getAdminSetting", "GET /api/v2/admin/stats": "getAdminDashboardStats", @@ -825,7 +825,6 @@ export const v2Operations = { "PUT /api/v2/admin/sections/defaults": "restoreAdminSections", "PUT /api/v2/admin/sections/order": "reorderAdminSections", "PUT /api/v2/admin/settings": "updateAdminSettings", - "PUT /api/v2/admin/settings/sections": "updateAdminSectionSettings", "PUT /api/v2/admin/settings/{key}": "updateAdminSetting", "PUT /api/v2/admin/subtitle-providers/{provider}": "updateAdminSubtitleProviderConfiguration", "PUT /api/v2/admin/tasks/{key}/triggers": "updateAdminTaskSchedule", diff --git a/web/src/api/v2/request.deadline.test.ts b/web/src/api/v2/request.deadline.test.ts index b8269eb937..ac987e8131 100644 --- a/web/src/api/v2/request.deadline.test.ts +++ b/web/src/api/v2/request.deadline.test.ts @@ -92,6 +92,16 @@ describe("v2 request deadline", () => { expect(await outcome).toBeInstanceOf(V2TimeoutError); }); + it("bounds the admin Home row preview, a read sent as POST", async () => { + vi.stubGlobal("fetch", hungFetch()); + + const outcome = v2("POST /api/v2/admin/sections/preview", { + body: { section_type: "recently_added", config: {} }, + }).catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(API_READ_TIMEOUT_MS); + expect(await outcome).toBeInstanceOf(V2TimeoutError); + }); + it("honours a caller's own deadline", async () => { vi.stubGlobal("fetch", hungFetch()); diff --git a/web/src/api/v2/request.ts b/web/src/api/v2/request.ts index 70da2c93c4..dbc7103978 100644 --- a/web/src/api/v2/request.ts +++ b/web/src/api/v2/request.ts @@ -53,6 +53,7 @@ const authExchangeOperations = new Set([ // the server only as long as a GET does. const readOnlyPostOperations = new Set([ "POST /api/v2/catalog/query", + "POST /api/v2/admin/sections/preview", "POST /api/v2/direct-download/links", ]); @@ -226,7 +227,8 @@ export type V2RequestOptions = CommonOptions & ? { headers?: V2Headers } : { headers: V2Headers }); -type RequestArgs = +/** The arguments after the operation key: options, optional when nothing is required. */ +export type V2RequestArgs = Record extends V2RequestOptions ? [options?: V2RequestOptions] : [options: V2RequestOptions]; @@ -405,7 +407,7 @@ async function readBody(res: Response, operationId: string): Promise { */ export async function v2( key: K, - ...args: RequestArgs + ...args: V2RequestArgs ): Promise> { const [method, route] = key.split(" ", 2) as [string, string]; const options = (args[0] ?? {}) as CommonOptions & { diff --git a/web/src/api/v2/schema.ts b/web/src/api/v2/schema.ts index ce6cab53f2..ede3feb4b2 100644 --- a/web/src/api/v2/schema.ts +++ b/web/src/api/v2/schema.ts @@ -887,6 +887,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v2/admin/collections/{id}/sections": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List the administrator Home and library page rows that show a collection. Rows profiles added themselves are not listed. */ + get: operations["listAdminCollectionSections"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v2/admin/collections/{id}/sync": { parameters: { query?: never; @@ -1031,7 +1048,7 @@ export interface paths { path?: never; cookie?: never; }; - /** List collection template bundles. */ + /** List collection template bundles, each with a summary of its templates. */ get: operations["listAdminCollectionTemplateBundles"]; put?: never; post?: never; @@ -1082,7 +1099,7 @@ export interface paths { path?: never; cookie?: never; }; - /** List supported collection templates. */ + /** List the collection templates with an mdblist, tmdb or tmdb_list source. */ get: operations["listAdminCollectionTemplates"]; put?: never; post?: never; @@ -4363,24 +4380,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v2/admin/settings/sections": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Read section permission configuration; read failures retain the disabled default. */ - get: operations["getAdminSectionSettings"]; - /** Replace section permission configuration under the existing settings transaction guard. */ - put: operations["updateAdminSectionSettings"]; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/v2/admin/settings/sensitive-status": { parameters: { query?: never; @@ -6137,7 +6136,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Prefix typeahead over one facet of a scope. */ + /** Typeahead over one facet of a scope, with title counts. */ get: operations["searchCatalogFacet"]; put?: never; post?: never; @@ -6459,7 +6458,7 @@ export interface paths { path?: never; cookie?: never; }; - /** List the collections the acting profile owns or may see, with the account's collection groups. */ + /** List the acting profile's own collections in its order, then the collections other profiles on the login share, grouped by creator and each in its creator's order. */ get: operations["listCollections"]; put?: never; /** Create a manual or smart collection for the acting profile. Not idempotent: a retry after a lost response creates a second collection. */ @@ -6485,7 +6484,7 @@ export interface paths { delete: operations["deleteCollection"]; options?: never; head?: never; - /** Update the creator's collection; omitted fields are unchanged. */ + /** Update the creator's collection; omitted fields are unchanged. Another profile's shared collection answers permission_denied. */ patch: operations["updateCollection"]; trace?: never; }; @@ -6619,7 +6618,7 @@ export interface paths { }; get?: never; put?: never; - /** Create an account-wide collection group. Not idempotent: a retry after a lost response creates a second group. */ + /** Not supported: personal collection groups were removed, and this operation answers capability_unsupported. */ post: operations["createCollectionGroup"]; delete?: never; options?: never; @@ -6634,15 +6633,15 @@ export interface paths { path?: never; cookie?: never; }; - /** Read the canonical collection editor state and its strong validator. */ + /** Not supported: personal collection groups were removed, and this operation answers capability_unsupported. */ get: operations["getCollectionGroup"]; put?: never; post?: never; - /** Delete a collection group; its collections become ungrouped. */ + /** Not supported: personal collection groups were removed, and this operation answers capability_unsupported. */ delete: operations["deleteCollectionGroup"]; options?: never; head?: never; - /** Update a collection group; omitted members are unchanged. Retries are not safe after an intervening mutation. */ + /** Not supported: personal collection groups were removed, and this operation answers capability_unsupported. */ patch: operations["updateCollectionGroup"]; trace?: never; }; @@ -6653,9 +6652,9 @@ export interface paths { path?: never; cookie?: never; }; - /** Read the canonical collection editor state and its strong validator. */ + /** Not supported: personal collection groups were removed, and this operation answers capability_unsupported. */ get: operations["getCollectionGroupsOrder"]; - /** Replace the order of the account's collection groups. Retries are not safe after an intervening mutation. */ + /** Not supported: personal collection groups were removed, and this operation answers capability_unsupported. */ put: operations["reorderCollectionGroups"]; post?: never; delete?: never; @@ -6775,7 +6774,7 @@ export interface paths { }; /** Read the canonical collection editor state and its strong validator. */ get: operations["getCollectionOrder"]; - /** Replace the order of the collections in one group (or the ungrouped section). Retries are not safe after an intervening mutation. */ + /** Replace the order of the acting profile's own collections. Retries are not safe after an intervening mutation. */ put: operations["reorderCollections"]; post?: never; delete?: never; @@ -12957,6 +12956,11 @@ export interface components { * @example 1 */ group_id: string | null; + /** + * Format: int64 + * @description Turned-on rows on the administrator Home page that show this collection; turned-off rows are not counted. Set on listAdminCollections items only. Rows profiles added themselves are not counted. + */ + home_row_count?: number; /** * @description Opaque identifier * @example 1 @@ -12985,9 +12989,19 @@ export interface components { * @description RFC 3339 instant in UTC with millisecond precision */ next_sync_at?: string; + /** + * @description poster_url is the acting profile's collage of the collection's first members it can access, composed by the server because the collection has no uploaded or template poster. False for an uploaded or template poster and whenever poster_url is empty, including before the collage is built and on reads that carry no poster_url + * @example false + */ + poster_is_collage: boolean; poster_thumbhash?: string; poster_url: string; query_definition: unknown; + /** + * Format: int64 + * @description Rows on the administrator Home and library pages that show this collection, turned-off rows included. Set on listAdminCollections items only. Rows profiles added themselves are not counted. + */ + row_count?: number; slug: string; sort_config: unknown; /** Format: int64 */ @@ -13029,13 +13043,29 @@ export interface components { import_sources: ("mdblist" | "tmdb" | "tmdb_list")[]; imports: boolean; item_reorder: boolean; + /** + * @description searchMDBListLists and listTopMDBListLists return lists; false when the server has no MDBList API key + * @example true + */ + mdblist_search: boolean; /** @description Opaque revision of this document */ revision: string; + schedule_time_zone: components["schemas"]["CollectionScheduleTimeZone"]; + /** + * @description listAdminCollectionSections lists the rows that show a collection, and listAdminCollections items carry home_row_count and row_count + * @example true + */ + section_references: boolean; /** * @description Support and configuration state, not health * @enum {string} */ state: "available" | "disabled" | "not_configured" | "unsupported"; + /** + * @description listAdminCollectionTemplateBundles returns each bundle's templates + * @example true + */ + template_summaries: boolean; }; AdminCollectionCreate: { backdrop_url?: string; @@ -13166,6 +13196,32 @@ export interface components { /** Format: int64 */ total: number; }; + AdminCollectionSection: { + enabled: boolean; + /** @description The row is its page's hero banner. */ + featured: boolean; + /** + * @description Opaque identifier + * @example 1 + */ + id: string; + /** + * @description The library whose page holds the row; null for a Home row. + * @example 1 + */ + library_id: string | null; + /** + * Format: int64 + * @description Rows on the page that holds this row, turned-off rows included. + */ + page_row_count: number; + /** Format: int64 */ + position: number; + /** @enum {string} */ + scope: "home" | "library"; + section_type: string; + title: string; + }; AdminCollectionSyncRun: { /** * @description Opaque identifier @@ -19137,10 +19193,24 @@ export interface components { order: string; }; CatalogFacetMatches: { - /** @description Whether more values matched than limit */ + /** @description Whether more values than limit start with q */ has_more: boolean; - /** @description Empty, never null */ + /** @description Values that start with q, case-insensitively, A-Z; empty for an empty q. Empty, never null */ matches: string[]; + /** @description The ranked answer, each value with its title count. For genre, studio, network, country, original_language and content_rating a value matches when it or any word in it starts with q; whole-value matches rank first, then more titles, then A-Z, and an empty q returns the most common values. For author, narrator and series these are the names in matches. Empty, never null */ + values: components["schemas"]["CatalogFacetValue"][]; + /** @description Whether more values matched than limit for values */ + values_has_more: boolean; + }; + CatalogFacetValue: { + /** + * Format: int64 + * @description Titles in the scope with this value; values can lag catalog changes by up to two minutes + * @example 42 + */ + count: number; + /** @example Warner Bros. Pictures */ + value: string; }; CatalogFilters: { /** @description First 1000 alphabetically; searchCatalogFacet pages the rest */ @@ -19676,6 +19746,8 @@ export interface components { CatalogSearchCapabilities: { /** @description Whether the current principal may use the capability */ allowed: boolean; + /** @description searchCatalogFacet accepts library_ids and answers values and values_has_more: ranked values with title counts that match word starts, and the most common values for an empty q */ + facet_value_search?: boolean; /** * Format: int64 * @description Oldest ranking sessions expire when this retention bound is exceeded @@ -19856,6 +19928,12 @@ export interface components { /** @description Cursor state; absent for bounded unpaginated collections */ page?: components["schemas"]["PageInfo"]; }; + CollectionAdminCollectionSection: { + /** @description The page's items; empty, never null */ + items: components["schemas"]["AdminCollectionSection"][]; + /** @description Cursor state; absent for bounded unpaginated collections */ + page?: components["schemas"]["PageInfo"]; + }; CollectionAdminDeviceMetadata: { /** @description The page's items; empty, never null */ items: components["schemas"]["AdminDeviceMetadata"][]; @@ -20057,6 +20135,16 @@ export interface components { collection_default_sort: boolean; /** @example true */ collection_sort_preferences: boolean; + /** + * @description listCollections accepts contains_item and marks the acting profile's own manual collections with contains + * @example true + */ + contains_item: boolean; + /** + * @description createCollection stores a description for the acting account + * @example true + */ + create_description: boolean; /** * @description Catalog query fields a display filter may use * @example [ @@ -20068,7 +20156,7 @@ export interface components { display_filter_presets: components["schemas"]["CollectionDisplayFilterPresets"]; /** @example true */ effective_collection_sort: boolean; - /** @description The acting account supports collection groups */ + /** @description Always false: personal collection groups are no longer supported */ groups: boolean; /** * @description Import sources the acting account can create a collection from; empty when imports is false @@ -20083,8 +20171,26 @@ export interface components { imports: boolean; /** @description The acting account supports reordering collection items */ item_reorder: boolean; + /** @description is_shared shows a collection to every profile on the login, listCollections includes other profiles' shared collections, and only a collection's creator changes or orders it */ + login_sharing: boolean; + /** + * @description searchMDBListLists and listTopMDBListLists return lists; false when the server has no MDBList API key + * @example true + */ + mdblist_search: boolean; + /** + * @description A collection with no uploaded or imported poster shows a collage of its first titles the acting profile can see in poster_url, marked by poster_is_collage, on listCollections, getLibraryCollections and listLibraryUserCollections, once the server has built it. False when the server has no artwork storage or the acting account's store keeps no artwork + * @example true + */ + poster_collages: boolean; + /** + * @description previewCollection items carry poster_url when the title has a poster + * @example true + */ + preview_posters: boolean; /** @description Opaque revision of this document */ revision: string; + schedule_time_zone: components["schemas"]["CollectionScheduleTimeZone"]; /** * @description collection_kind values the sort-preference operations accept * @example [ @@ -20100,6 +20206,11 @@ export interface components { * @enum {string} */ state: "available" | "disabled" | "not_configured" | "unsupported"; + /** + * @description updateCollection accepts sync_schedule on a synced list; false when imports is false + * @example true + */ + sync_schedule_editable: boolean; }; CollectionDisplayFilterPresets: { /** @@ -20238,12 +20349,12 @@ export interface components { }; CollectionOrder: { /** - * @description The group whose collections are ordered; omitted or null orders the ungrouped section + * @description Omit or send null: personal collection groups are no longer supported, and any other value is a validation failure * @example g1 */ group_id?: string | null; /** - * @description Every visible collection in the scope, exactly once, in the new order + * @description Each of the acting profile's own collections, exactly once, in the new order; another profile's collection is a validation failure * @example [ * "01J9Z8C3W4R5T6Y7U8I9O0P1Q4" * ] @@ -20268,6 +20379,23 @@ export interface components { /** @description Cursor state; absent for bounded unpaginated collections */ page?: components["schemas"]["PageInfo"]; }; + CollectionScheduleTimeZone: { + /** + * @description Current zone abbreviation as the node's time zone database reports it; some zones report a numeric form such as -03 + * @example CDT + */ + abbreviation: string; + /** + * @description IANA zone name, from the node's TZ environment variable or UTC when TZ is empty or names no known zone; omitted when the node uses its system default zone or a TZ file path + * @example America/Chicago + */ + name?: string; + /** + * @description Current offset from UTC, daylight saving time included + * @example -05:00 + */ + utc_offset: string; + }; CollectionSortPreference: { /** * @description Opaque identifier @@ -20339,11 +20467,25 @@ export interface components { description: string; id: string; template_ids: string[]; + /** @description The bundle's templates, in template_ids order. */ + templates: components["schemas"]["CollectionTemplateSummary"][]; title: string; }; CollectionTemplateCatalog: { categories: components["schemas"]["CategoryGroup"][]; }; + CollectionTemplateSummary: { + /** @description Collections created from this template are pinned first on their shelf. */ + featured: boolean; + id: string; + media_kind: string; + /** @description Applying the template creates an empty collection that cannot sync until an administrator sets its source. */ + needs_setup: boolean; + /** @description Server-relative path of the template's poster image. */ + poster_path?: string; + source: string; + title: string; + }; CollectionUserLibrary: { /** @description The page's items; empty, never null */ items: components["schemas"]["UserLibrary"][]; @@ -20481,6 +20623,11 @@ export interface components { * @description RFC 3339 instant in UTC with millisecond precision */ next_sync_at?: string; + /** + * @description poster_url is the acting profile's collage of the collection's first titles it can see, composed by the server because the collection has no uploaded, template or imported poster. False for an uploaded, template or imported poster and whenever poster_url is empty. See getCollectionCapabilities poster_collages + * @example false + */ + poster_is_collage: boolean; poster_thumbhash?: string; /** @description Presigned, short-lived; empty when none */ poster_url: string; @@ -22857,6 +23004,11 @@ export interface components { * @example 12 */ item_count: number; + /** + * @description poster_url is the acting profile's collage of the collection's first titles it can see, composed by the server because the collection has no uploaded, template or imported poster. False for an uploaded, template or imported poster and whenever poster_url is empty. See getCollectionCapabilities poster_collages + * @example false + */ + poster_is_collage: boolean; poster_thumbhash?: string; /** @description Presigned, short-lived; empty when none */ poster_url: string; @@ -23675,10 +23827,13 @@ export interface components { description?: string; /** @description Display filter fragment */ display_query_definition?: unknown; - /** @example false */ + /** + * @description Show the collection to every profile on the login; defaults to false + * @example false + */ is_shared?: boolean; /** - * @description Libraries the sync matches against; every library when omitted + * @description Libraries the sync matches against, limited to the libraries the collection's owner can access; every library the owner can access when omitted * @example [ * "1" * ] @@ -24999,16 +25154,16 @@ export interface components { scopes: components["schemas"]["APIKeyScope"][]; }; PersonalCollection: { - /** - * @description Profiles a shared collection is limited to; empty means every profile on the account - * @example [] - */ - allowed_profile_ids: string[]; /** * @description manual, smart, or an import source (mdblist, tmdb, trakt) * @example manual */ collection_type: string; + /** + * @description Whether the collection holds the listCollections contains_item title. Present only when contains_item is sent, and then only on the acting profile's own manual collections; false for a title the profile cannot access + * @example true + */ + contains?: boolean; /** * Format: date-time * @description RFC 3339 instant in UTC with millisecond precision @@ -25025,7 +25180,7 @@ export interface components { /** @description Display filter fragment; absent when none */ display_query_definition?: unknown; /** - * @description null when ungrouped + * @description Always null: personal collection groups are no longer supported * @example g1 */ group_id: string | null; @@ -25036,7 +25191,10 @@ export interface components { id: string; /** @example false */ include_in_server_collections: boolean; - /** @example false */ + /** + * @description Every profile on the login sees the collection read-only, including profiles added later; otherwise only its creator does + * @example false + */ is_shared: boolean; /** * Format: int64 @@ -25064,6 +25222,11 @@ export interface components { * @example 2026-01-02T03:04:05.678Z */ next_sync_at: string | null; + /** + * @description poster_url is a collage of the collection's first titles the acting profile can see, composed by the server because the collection has no uploaded or imported poster. False for an uploaded or imported poster and whenever poster_url is empty: before the collage is built, when no title the profile can see has a poster, and on getCollection and updateCollection, which carry no poster_url for any poster because their body sits behind a strong ETag. See getCollectionCapabilities poster_collages + * @example false + */ + poster_is_collage: boolean; /** @example */ poster_thumbhash: string; /** @@ -25082,6 +25245,7 @@ export interface components { sort_config: unknown; /** * Format: int64 + * @description Position in its creator's own order of collections * @example 0 */ sort_order: number; @@ -25092,6 +25256,12 @@ export interface components { * @example */ source_url: string; + /** + * @description The cadence sync_schedule names; empty when the collection is not synced, custom for a schedule no cadence name produces + * @example + * @enum {string} + */ + sync_cadence: "" | "daily" | "weekly" | "monthly" | "custom"; /** * @description Empty when the collection is not synced * @example @@ -25109,7 +25279,7 @@ export interface components { position: number; }; PersonalCollectionCollection: { - /** @description The account's collection groups in sort order; empty, never null */ + /** @description Always empty: personal collection groups are no longer supported */ groups: components["schemas"]["CollectionGroup"][]; /** @description The page's items; empty, never null */ items: components["schemas"]["PersonalCollection"][]; @@ -25117,22 +25287,25 @@ export interface components { page?: components["schemas"]["PageInfo"]; }; PersonalCollectionCreate: { - /** - * @description Profiles a shared collection is limited to - * @example [] - */ - allowed_profile_ids?: string[]; /** * @description Defaults to manual * @example manual * @enum {string} */ collection_type?: "manual" | "smart"; + /** + * @description Empty when omitted. Send only when getCollectionCapabilities reports create_description; otherwise the request fails + * @example For wet afternoons + */ + description?: string; /** @description Display filter fragment */ display_query_definition?: unknown; /** @example false */ include_in_server_collections?: boolean; - /** @example false */ + /** + * @description Show the collection to every profile on the login; defaults to false + * @example false + */ is_shared?: boolean; /** @example Rainy days */ name: string; @@ -25189,6 +25362,8 @@ export interface components { * @example 1 */ content_id: string; + /** @description Card-size poster URL; omitted when the item has none */ + poster_url?: string; title: string; type: string; }; @@ -25201,15 +25376,15 @@ export interface components { total: number; }; PersonalCollectionUpdate: { - allowed_profile_ids?: string[]; description?: string; display_query_definition?: unknown; /** - * @description Null removes the group; omitted leaves it unchanged + * @description Omit: personal collection groups are no longer supported, and setting it answers capability_unsupported * @example 1 */ group_id?: string | null; include_in_server_collections?: boolean; + /** @description Show the collection to every profile on the login */ is_shared?: boolean; library_ids?: string[]; /** Format: int64 */ @@ -25219,6 +25394,12 @@ export interface components { query_definition?: unknown; sort_config?: unknown; source_url?: string; + /** + * @description A synced list's cadence; empty stops scheduled syncs. Cron expressions are refused. Accepted when getCollectionCapabilities reports sync_schedule_editable + * @example weekly + * @enum {string} + */ + sync_schedule?: "" | "daily" | "weekly" | "monthly"; }; PersonCollection: { /** @description The page's items; empty, never null */ @@ -26472,8 +26653,9 @@ export interface components { }; ProfileSectionFlags: { /** - * @description Whether non-admin profiles may build sections from admin-only recipes - * @example false + * @deprecated + * @description Deprecated; always true. Profiles may always add rule rows (custom_filter). Whether a profile that is not an admin may add a new row of a recipe is that recipe's admin_only in listSectionRecipes; follow it instead of this flag. Kept for clients that still read it. + * @example true */ allow_profile_custom_sections: boolean; }; @@ -26487,6 +26669,11 @@ export interface components { * @example true */ customized: boolean; + /** + * @description The administrator's title for this row; title shows it unless the profile saved a title override. Empty for a section the profile built + * @example Continue Watching + */ + default_title: string; /** @example false */ featured: boolean; /** @@ -26516,7 +26703,10 @@ export interface components { * @example continue_watching */ section_type: string; - /** @example Continue Watching */ + /** + * @description The title this profile sees: its own title override, or the administrator's title + * @example Continue Watching + */ title: string; }; ProfileSectionSettingCollection: { @@ -26982,7 +27172,10 @@ export interface components { recipes: components["schemas"]["RecipeDefinition"][]; }; RecipeDefinition: { - /** @example false */ + /** + * @description Only an admin may add a new section of this recipe; a profile keeps and changes the ones it already has + * @example false + */ admin_only: boolean; /** @example true */ avoid_duplicates: boolean; @@ -29061,10 +29254,13 @@ export interface components { description?: string; /** @description Display filter fragment */ display_query_definition?: unknown; - /** @example false */ + /** + * @description Show the collection to every profile on the login; defaults to false + * @example false + */ is_shared?: boolean; /** - * @description Libraries the sync matches against; every library when omitted + * @description Libraries the sync matches against, limited to the libraries the collection's owner can access; every library the owner can access when omitted * @example [ * "1" * ] @@ -29137,10 +29333,13 @@ export interface components { description?: string; /** @description Display filter fragment */ display_query_definition?: unknown; - /** @example false */ + /** + * @description Show the collection to every profile on the login; defaults to false + * @example false + */ is_shared?: boolean; /** - * @description Libraries the sync matches against; every library when omitted + * @description Libraries the sync matches against, limited to the libraries the collection's owner can access; every library the owner can access when omitted * @example [ * "1" * ] @@ -29246,10 +29445,13 @@ export interface components { description?: string; /** @description Display filter fragment */ display_query_definition?: unknown; - /** @example false */ + /** + * @description Show the collection to every profile on the login; defaults to false + * @example false + */ is_shared?: boolean; /** - * @description Libraries the sync matches against; every library when omitted + * @description Libraries the sync matches against, limited to the libraries the collection's owner can access; every library the owner can access when omitted * @example [ * "1" * ] @@ -29368,6 +29570,11 @@ export interface components { item_count: number; /** @example Rainy days */ name: string; + /** + * @description poster_url is the acting profile's collage of the collection's first titles it can see, composed by the server because the collection has no uploaded, template or imported poster. False for an uploaded, template or imported poster and whenever poster_url is empty. See getCollectionCapabilities poster_collages + * @example false + */ + poster_is_collage: boolean; poster_thumbhash?: string; /** @description Presigned, short-lived */ poster_url?: string; @@ -39891,6 +40098,117 @@ export interface operations { }; }; }; + listAdminCollectionSections: { + parameters: { + query?: never; + header?: { + "If-Match"?: string; + "If-None-Match"?: string; + /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ + "X-Profile-Id"?: string; + /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ + "X-Profile-Token"?: string; + }; + path: { + /** @description Opaque identifier */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CollectionAdminCollectionSection"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Not Acceptable */ + 406: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Too Many Requests */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; + }; + }; syncAdminCollection: { parameters: { query?: never; @@ -71319,223 +71637,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unprocessable Entity */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Too Many Requests */ - 429: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Internal Server Error */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Service Unavailable */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - }; - }; - getAdminEffectiveSettings: { - parameters: { - query?: never; - header?: { - /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ - "X-Profile-Id"?: string; - /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ - "X-Profile-Token"?: string; - }; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - ETag?: string; - [name: string]: unknown; - }; - content: { - "application/json": { - [key: string]: string; - }; - }; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Forbidden */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Not Found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Not Acceptable */ - 406: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unprocessable Entity */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Too Many Requests */ - 429: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Internal Server Error */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Service Unavailable */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - }; - }; - getAdminRestartKeys: { - parameters: { - query?: never; - header?: { - /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ - "X-Profile-Id"?: string; - /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ - "X-Profile-Token"?: string; - }; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["AdminRestartKeys"]; - }; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Forbidden */ - 403: { + /** @description Request Timeout */ + 408: { headers: { [name: string]: unknown; }; @@ -71543,8 +71646,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Not Found */ - 404: { + /** @description Request Entity Too Large */ + 413: { headers: { [name: string]: unknown; }; @@ -71552,8 +71655,8 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Not Acceptable */ - 406: { + /** @description Unsupported Media Type */ + 415: { headers: { [name: string]: unknown; }; @@ -71599,13 +71702,10 @@ export interface operations { }; }; }; - getAdminSectionSettings: { + getAdminEffectiveSettings: { parameters: { query?: never; header?: { - /** @description Optional first precondition, evaluated before If-None-Match: a tag that does not match the current representation is 412 precondition_failed. */ - "If-Match"?: string; - "If-None-Match"?: string; /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ @@ -71619,22 +71719,14 @@ export interface operations { /** @description OK */ 200: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProfileSectionFlags"]; - }; - }; - /** @description The representation named by If-None-Match is current; no body. */ - 304: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; + "application/json": { + [key: string]: string; + }; }; - content?: never; }; /** @description Bad Request */ 400: { @@ -71681,17 +71773,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Failed */ - 412: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -71730,14 +71811,10 @@ export interface operations { }; }; }; - updateAdminSectionSettings: { + getAdminRestartKeys: { parameters: { query?: never; - header: { - /** @description The resource's current ETag, or "*" to overwrite deliberately. A missing field is 428 precondition_required; a stale tag is 412 precondition_failed with the current ETag. */ - "If-Match": string; - /** @description Optional second precondition, evaluated after If-Match succeeds: "*" or any tag matching the current representation is 412 precondition_failed with the current ETag. */ - "If-None-Match"?: string; + header?: { /** @description Optional. When present, it must name the authenticated account's primary profile; an absent header is accepted. */ "X-Profile-Id"?: string; /** @description Verification proof for a PIN-locked profile, issued by POST /api/v2/profiles/{id}/verify-pin; required only when the declared profile is locked */ @@ -71746,21 +71823,15 @@ export interface operations { path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["ProfileSectionFlags"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProfileSectionFlags"]; + "application/json": components["schemas"]["AdminRestartKeys"]; }; }; /** @description Bad Request */ @@ -71808,44 +71879,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Request Timeout */ - 408: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Precondition Failed */ - 412: { - headers: { - /** @description The strong, opaque validator of the representation; send it back in If-Match on a guarded mutation or If-None-Match on a conditional read. */ - ETag?: string; - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Request Entity Too Large */ - 413: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; - /** @description Unsupported Media Type */ - 415: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Unprocessable Entity */ 422: { headers: { @@ -71855,15 +71888,6 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; - /** @description Precondition Required */ - 428: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["Problem"]; - }; - }; /** @description Too Many Requests */ 429: { headers: { @@ -84877,6 +84901,8 @@ export interface operations { collection_id?: string; /** @description Opaque identifier */ library_id?: string; + /** @description Restrict to several libraries, one library_ids parameter per id; combines with library_id. Libraries the viewer cannot see are dropped, and a scope left with none is empty. Not accepted with source=section */ + library_ids?: string[]; /** @description Opaque identifier */ person_id?: string; scope?: "home" | "library"; @@ -85016,11 +85042,13 @@ export interface operations { | "series"; /** @description Opaque identifier */ library_id?: string; - /** @description Most matches to return; default 20, maximum 100 */ + /** @description Restrict to several libraries, one library_ids parameter per id; combines with library_id. Libraries the viewer cannot see are dropped, and a scope left with none is empty. Not accepted with source=section */ + library_ids?: string[]; + /** @description Most values to return in matches and in values; default 20, maximum 100 */ limit?: number; /** @description Opaque identifier */ person_id?: string; - /** @description Case-insensitive prefix */ + /** @description Case-insensitive search text; see matches and values for how each answers it */ q?: string; scope?: "home" | "library"; section_id?: string; @@ -87484,7 +87512,10 @@ export interface operations { }; listCollections: { parameters: { - query?: never; + query?: { + /** @description A title's content id. Each of the acting profile's own manual collections then carries contains. Accepted when getCollectionCapabilities reports contains_item */ + contains_item?: string; + }; header: { /** @description The household profile acting for this request; it must belong to the authenticated account. */ "X-Profile-Id": string; @@ -87715,6 +87746,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Service Unavailable */ 503: { headers: { @@ -88124,6 +88164,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Service Unavailable */ 503: { headers: { @@ -89395,6 +89444,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Service Unavailable */ 503: { headers: { @@ -89507,6 +89565,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Service Unavailable */ 503: { headers: { @@ -89638,6 +89705,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Service Unavailable */ 503: { headers: { @@ -89804,6 +89880,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Service Unavailable */ 503: { headers: { @@ -89911,6 +89996,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Service Unavailable */ 503: { headers: { @@ -90074,6 +90168,15 @@ export interface operations { "application/problem+json": components["schemas"]["Problem"]; }; }; + /** @description Not Implemented */ + 501: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["Problem"]; + }; + }; /** @description Service Unavailable */ 503: { headers: { @@ -90855,6 +90958,7 @@ export interface operations { getCollectionOrder: { parameters: { query?: { + /** @description Omit: personal collection groups are no longer supported, and any value is a validation failure */ group_id?: string; }; header: { diff --git a/web/src/app.css b/web/src/app.css index 28d237a2d0..322c6dcdbd 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -2315,6 +2315,10 @@ html[data-navigation-direction]::view-transition-new(main-content) { already hidden the sidebar (#1816). */ :root { --app-sidebar-offset: 0px; + /* How far fixed page chrome (the collection editor's save bar) rises to + clear whichever background playback bar shows. usePlaybackBarHeight + measures each bar, bottom inset included, on the root while it shows. */ + --playback-bar-clearance: max(var(--watch-bar-height, 0px), var(--audiobook-bar-height, 0px)); } @media (min-width: 64rem) { :root[data-app-shell] { diff --git a/web/src/components/AddToCollectionDialog.goldens.test.tsx b/web/src/components/AddToCollectionDialog.goldens.test.tsx new file mode 100644 index 0000000000..569db295f0 --- /dev/null +++ b/web/src/components/AddToCollectionDialog.goldens.test.tsx @@ -0,0 +1,85 @@ +/** + * Goldens: what the Add to collection dialog lists, what it reads and which + * route an add takes. It lists only the profile's own manual collections, for + * an acting admin too: the server collections group acting admins used to get + * is gone (they add titles to a server collection from its editor). + */ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { goldens } from "@/test/fixtures/collectionBodies"; +import { installV2Recorder, v2Recorder } from "@/test/v2Recorder"; +import AddToCollectionDialog from "./AddToCollectionDialog"; + +vi.mock("@/api/v2/request", async () => (await import("@/test/v2Recorder")).mockV2Request()); +const account = vi.hoisted(() => ({ actingAdmin: false })); +vi.mock("@/hooks/useIsActingAdmin", () => ({ useIsActingAdmin: () => account.actingAdmin })); +vi.mock("@/hooks/useCurrentProfile", () => ({ + useCurrentProfile: () => ({ profile: { id: "p-owner" } }), +})); +vi.mock("@/hooks/queries/libraries", async () => ({ + ...(await vi.importActual( + "@/hooks/queries/libraries", + )), + useUserLibraries: () => ({ data: [{ id: 1, name: "Movies", type: "movies" }] }), +})); +vi.mock("sonner", () => ({ toast: { success: vi.fn(), warning: vi.fn(), error: vi.fn() } })); + +installV2Recorder(); + +beforeEach(() => { + account.actingAdmin = false; +}); + +function show() { + render( + + + + + , + ); +} + +/** The collections the dialog offers, by the name on each checkbox. */ +function listedChoices() { + return within(screen.getByRole("dialog")) + .getAllByRole("checkbox") + .map((box) => box.getAttribute("aria-label")); +} + +describe("Add to collection", () => { + it("lists only the profile's own manual collections for a regular profile", async () => { + show(); + await screen.findByRole("checkbox", { name: "Rainy days" }); + expect(listedChoices()).toEqual(goldens.addToCollectionChoices); + expect(v2Recorder.calls).toEqual(goldens.addToCollectionReads); + }); + + it("lists the same for an acting admin and reads no library's collections", async () => { + account.actingAdmin = true; + show(); + await screen.findByRole("checkbox", { name: "Rainy days" }); + expect(listedChoices()).toEqual(goldens.addToCollectionChoices); + expect(v2Recorder.calls).toEqual(goldens.addToCollectionReads); + }); + + it("adds a title to a personal collection through the personal route", async () => { + show(); + fireEvent.click(await screen.findByRole("checkbox", { name: "Rainy days" })); + await waitFor(() => expect(v2Recorder.writes()).toEqual(goldens.addToPersonalCollection)); + }); +}); diff --git a/web/src/components/AddToCollectionDialog.test.tsx b/web/src/components/AddToCollectionDialog.test.tsx new file mode 100644 index 0000000000..e317100d5f --- /dev/null +++ b/web/src/components/AddToCollectionDialog.test.tsx @@ -0,0 +1,344 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { MemoryRouter, Route, Routes, useLocation } from "react-router"; +import { toast } from "sonner"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import createCollectionOk from "../../../contracts/api/v2/fixtures/create_collection_ok.json"; +import listCollectionsContainsItemOk from "../../../contracts/api/v2/fixtures/list_collections_contains_item_ok.json"; +import listCollectionsOk from "../../../contracts/api/v2/fixtures/list_collections_ok.json"; +import { goldens } from "@/test/fixtures/collectionBodies"; +import { installV2Recorder, v2Recorder } from "@/test/v2Recorder"; +import AddToCollectionDialog from "./AddToCollectionDialog"; + +vi.mock("@/api/v2/request", async () => (await import("@/test/v2Recorder")).mockV2Request()); +const account = vi.hoisted(() => ({ actingAdmin: false, profileId: "p-owner" as string | null })); +vi.mock("@/hooks/useIsActingAdmin", () => ({ useIsActingAdmin: () => account.actingAdmin })); +vi.mock("@/hooks/useCurrentProfile", () => ({ + useCurrentProfile: () => ({ profile: account.profileId ? { id: account.profileId } : null }), +})); +vi.mock("sonner", () => ({ toast: { success: vi.fn(), warning: vi.fn(), error: vi.fn() } })); + +installV2Recorder(); + +const ITEM = "movie:heat-1995"; +const [rainyDays, familyNight] = listCollectionsOk.items; + +beforeEach(() => { + vi.clearAllMocks(); + account.actingAdmin = false; + account.profileId = "p-owner"; +}); + +function Location() { + return {useLocation().pathname}; +} + +function show() { + const onOpenChange = vi.fn(); + render( + + + + + + + + } + /> + + + , + ); + return onOpenChange; +} + +/** Answers the list with these collections; `collections` may change between reads. */ +function listing(collections: () => unknown[]) { + v2Recorder.answer("GET /api/v2/collections", () => ({ items: collections(), groups: [] })); +} + +function dialog() { + return within(screen.getByRole("dialog")); +} + +async function createAndAdd(name: string) { + fireEvent.change(await dialog().findByRole("textbox", { name: "New manual collection" }), { + target: { value: name }, + }); + fireEvent.click(dialog().getByRole("button", { name: "Create and add" })); +} + +describe("AddToCollectionDialog", () => { + it("ticks the collections that already hold the title", async () => { + v2Recorder.answer("GET /api/v2/collections", listCollectionsContainsItemOk); + show(); + const box = await dialog().findByRole("checkbox", { name: "Rainy days" }); + expect(box.getAttribute("aria-checked")).toBe("true"); + expect(dialog().getByText("In 1 collection")).toBeTruthy(); + }); + + it("removes the title when its tick is cleared, and says so", async () => { + v2Recorder.answer("GET /api/v2/collections", listCollectionsContainsItemOk); + show(); + fireEvent.click(await dialog().findByRole("checkbox", { name: "Rainy days" })); + await waitFor(() => + expect(v2Recorder.writes()).toEqual([ + { + operation: "DELETE /api/v2/collections/{id}/items/{item_id}", + path: `/api/v2/collections/c1/items/${ITEM}`, + headers: {}, + }, + ]), + ); + expect(await dialog().findByText("Removed")).toBeTruthy(); + expect( + dialog().getByRole("checkbox", { name: "Rainy days" }).getAttribute("aria-checked"), + ).toBe("false"); + }); + + it("flashes Added when a tick saves", async () => { + show(); + fireEvent.click(await dialog().findByRole("checkbox", { name: "Rainy days" })); + expect(await dialog().findByText("Added")).toBeTruthy(); + expect(dialog().getByText("In 1 collection")).toBeTruthy(); + }); + + it("drops this dialog's ticks when it moves to another title", async () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const at = (item: string) => ( + + + + + + ); + const { rerender } = render(at(ITEM)); + fireEvent.click(await dialog().findByRole("checkbox", { name: "Rainy days" })); + expect(await dialog().findByText("In 1 collection")).toBeTruthy(); + + rerender(at("movie:ronin-1998")); + expect(await dialog().findByText("Not in a collection yet")).toBeTruthy(); + expect( + dialog().getByRole("checkbox", { name: "Rainy days" }).getAttribute("aria-checked"), + ).toBe("false"); + }); + + it("puts the tick back and reports it when the add fails", async () => { + v2Recorder.answer("PUT /api/v2/collections/{id}/items/{item_id}", () => { + throw new Error("The server is busy"); + }); + show(); + const box = await dialog().findByRole("checkbox", { name: "Rainy days" }); + fireEvent.click(box); + await waitFor(() => expect(toast.error).toHaveBeenCalled()); + await waitFor(() => expect(box.getAttribute("aria-checked")).toBe("false")); + }); + + it("puts the tick back when the add fails after a search hid its collection", async () => { + const add: { fail?: (error: Error) => void } = {}; + v2Recorder.answer( + "PUT /api/v2/collections/{id}/items/{item_id}", + () => new Promise((_, reject) => (add.fail = reject)), + ); + listing(() => [rainyDays, { ...rainyDays, id: "c5", name: "Heist films" }]); + show(); + fireEvent.click(await dialog().findByRole("checkbox", { name: "Rainy days" })); + expect(dialog().getByText("In 1 collection")).toBeTruthy(); + await waitFor(() => expect(add.fail).toBeDefined()); + const search = dialog().getByRole("searchbox", { name: "Find one of your collections" }); + fireEvent.change(search, { target: { value: "heist" } }); + expect(dialog().queryByRole("checkbox", { name: "Rainy days" })).toBeNull(); + + await act(async () => add.fail?.(new Error("The server is busy"))); + await waitFor(() => expect(toast.error).toHaveBeenCalled()); + await waitFor(() => expect(dialog().getByText("Not in a collection yet")).toBeTruthy()); + fireEvent.change(search, { target: { value: "" } }); + expect( + dialog().getByRole("checkbox", { name: "Rainy days" }).getAttribute("aria-checked"), + ).toBe("false"); + }); + + it("leaves out other profiles' shared collections and collections that fill themselves", async () => { + listing(() => [ + rainyDays, + familyNight, + { ...rainyDays, id: "c3", name: "Smart picks", collection_type: "smart" }, + { ...rainyDays, id: "c4", name: "Top 250", collection_type: "mdblist" }, + ]); + show(); + await dialog().findByRole("checkbox", { name: "Rainy days" }); + expect(dialog().getAllByRole("checkbox")).toHaveLength(1); + expect(dialog().queryByText("Family night")).toBeNull(); + expect( + dialog().getByText("Only manual collections take titles by hand.", { exact: false }), + ).toBeTruthy(); + }); + + it("marks the profile's own shared collections Shared", async () => { + listing(() => [{ ...rainyDays, is_shared: true }]); + show(); + await dialog().findByRole("checkbox", { name: "Rainy days" }); + expect(dialog().getByText("Shared")).toBeTruthy(); + expect(dialog().getByText("Manual · 4 titles")).toBeTruthy(); + }); + + it("finds a collection by name", async () => { + listing(() => [rainyDays, { ...rainyDays, id: "c5", name: "Heist films" }]); + show(); + await dialog().findByRole("checkbox", { name: "Rainy days" }); + fireEvent.change(dialog().getByRole("searchbox", { name: "Find one of your collections" }), { + target: { value: "heist" }, + }); + expect(dialog().queryByRole("checkbox", { name: "Rainy days" })).toBeNull(); + expect(dialog().getByRole("checkbox", { name: "Heist films" })).toBeTruthy(); + // The inline create row stays while searching. + expect(dialog().getByRole("textbox", { name: "New manual collection" })).toBeTruthy(); + }); + + it("lists nothing while the acting profile is unknown", async () => { + account.profileId = null; + let served = false; + listing(() => { + served = true; + return [rainyDays]; + }); + show(); + await waitFor(() => expect(served).toBe(true)); + // Let the list read settle, so only the unknown profile keeps the dialog waiting. + await act(() => new Promise((resolve) => setTimeout(resolve, 20))); + expect(dialog().getByText("Loading collections…")).toBeTruthy(); + expect(dialog().queryByText("Start your first collection")).toBeNull(); + expect(dialog().queryByRole("textbox", { name: "New manual collection" })).toBeNull(); + expect(dialog().queryByRole("checkbox")).toBeNull(); + expect(dialog().queryByText("Rainy days")).toBeNull(); + }); + + it("says the list couldn't load instead of offering a first collection, and retries", async () => { + let fail = true; + listing(() => { + if (fail) throw new Error("The server is busy"); + return [rainyDays]; + }); + show(); + expect(await dialog().findByText("Couldn't load your collections")).toBeTruthy(); + expect(dialog().queryByText("Start your first collection")).toBeNull(); + expect(dialog().queryByRole("textbox", { name: "New manual collection" })).toBeNull(); + expect(dialog().getByRole("button", { name: "Cancel" })).toBeTruthy(); + + fail = false; + fireEvent.click(dialog().getByRole("button", { name: "Try again" })); + expect(await dialog().findByRole("checkbox", { name: "Rainy days" })).toBeTruthy(); + }); + + it("keeps focus on a tick while it saves, and ignores a second press until it lands", async () => { + show(); + const box = await dialog().findByRole("checkbox", { name: "Rainy days" }); + act(() => box.focus()); + fireEvent.click(box); + expect(box.getAttribute("aria-disabled")).toBe("true"); + expect((box as HTMLButtonElement).disabled).toBe(false); + expect(document.activeElement).toBe(box); + fireEvent.click(box); + expect(await dialog().findByText("Added")).toBeTruthy(); + expect(v2Recorder.writes().map((call) => call.operation)).toEqual([ + "PUT /api/v2/collections/{id}/items/{item_id}", + ]); + expect(box.getAttribute("aria-checked")).toBe("true"); + expect(document.activeElement).toBe(box); + }); + + it("offers an inline create when the profile has no manual collection", async () => { + let created = false; + listing(() => (created ? [createCollectionOk] : [familyNight])); + v2Recorder.answer("POST /api/v2/collections", () => { + created = true; + return createCollectionOk; + }); + show(); + expect(await dialog().findByText("Start your first collection")).toBeTruthy(); + expect(dialog().getByRole("link", { name: "New smart collection" }).getAttribute("href")).toBe( + "/collections/new?type=smart", + ); + await createAndAdd("Night in"); + await waitFor(() => expect(v2Recorder.writes()).toEqual(goldens.addToNewCollection)); + const box = await dialog().findByRole("checkbox", { name: "Rainy days" }); + expect(box.getAttribute("aria-checked")).toBe("true"); + }); + + it("creates a personal collection for an acting admin too", async () => { + account.actingAdmin = true; + show(); + await dialog().findByRole("checkbox", { name: "Rainy days" }); + await createAndAdd("Night in"); + await waitFor(() => expect(v2Recorder.writes()).toEqual(goldens.addToNewCollection)); + expect(v2Recorder.operations().filter((op) => op.includes("/admin/"))).toEqual([]); + expect(v2Recorder.operations()).not.toContain("GET /api/v2/library/{id}/collections"); + }); + + it("keeps a new collection when the title can't be added, and retries only the add", async () => { + let failAdd = true; + v2Recorder.answer("PUT /api/v2/collections/{id}/items/{item_id}", () => { + if (failAdd) throw new Error("The server is busy"); + return undefined; + }); + listing(() => []); + show(); + await createAndAdd("Night in"); + await waitFor(() => expect(toast.warning).toHaveBeenCalled()); + const [message, options] = vi.mocked(toast.warning).mock.calls[0]!; + expect(message).toBe("Made Night in, but couldn't add Heat"); + expect(v2Recorder.operations((call) => call.operation.startsWith("DELETE"))).toEqual([]); + + const writesBefore = v2Recorder.writes().length; + failAdd = false; + const tryAgain = (options as { action: { label: string; onClick: () => void } }).action; + expect(tryAgain.label).toBe("Try again"); + await act(async () => tryAgain.onClick()); + await waitFor(() => expect(v2Recorder.writes().length).toBe(writesBefore + 1)); + expect(v2Recorder.writes().at(-1)?.operation).toBe( + "PUT /api/v2/collections/{id}/items/{item_id}", + ); + }); + + it("opens the new collection from the toast when the add failed", async () => { + v2Recorder.answer("PUT /api/v2/collections/{id}/items/{item_id}", () => { + throw new Error("The server is busy"); + }); + listing(() => []); + show(); + await createAndAdd("Night in"); + await waitFor(() => expect(toast.warning).toHaveBeenCalled()); + const options = vi.mocked(toast.warning).mock.calls[0]![1] as { + cancel: { label: string; onClick: () => void }; + }; + expect(options.cancel.label).toBe("Open it"); + act(() => options.cancel.onClick()); + expect(screen.getByRole("status", { name: "location", hidden: true }).textContent).toBe( + "/collections/c1/edit", + ); + }); + + it("closes from Done", async () => { + const onOpenChange = show(); + await dialog().findByRole("checkbox", { name: "Rainy days" }); + fireEvent.click(dialog().getByRole("button", { name: "Done" })); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/web/src/components/AddToCollectionDialog.tsx b/web/src/components/AddToCollectionDialog.tsx index 6e948905b9..77593987a2 100644 --- a/web/src/components/AddToCollectionDialog.tsx +++ b/web/src/components/AddToCollectionDialog.tsx @@ -1,6 +1,12 @@ -import { useMemo, useState } from "react"; -import { FolderPlus, Loader2 } from "lucide-react"; +import { useEffect, useId, useMemo, useState, type FormEvent } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Film, FolderPlus, ListPlus, Loader2, Plus, Search, Users } from "lucide-react"; +import { Link, useNavigate } from "react-router"; +import { toast } from "sonner"; +import type { Collection } from "@/api/types"; +import { PosterArt } from "@/components/calm/PosterTile"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, @@ -9,36 +15,44 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { useCollections, useAddItemToCollection } from "@/hooks/queries/collections"; -import { useUserLibraries } from "@/hooks/queries/libraries"; -import { useQueries } from "@tanstack/react-query"; +import { Input } from "@/components/ui/input"; import { - getLibraryCollectionList, - libraryCollectionsQueryOptions, -} from "@/hooks/queries/libraryCollections"; -import { useIsActingAdmin } from "@/hooks/useIsActingAdmin"; + useAddItemToCollection, + useCollectionsContaining, + useRemoveCollectionItem, +} from "@/hooks/queries/collections"; +import { putCollectionItem } from "@/hooks/queries/collectionScope"; +import { useCurrentProfile } from "@/hooks/useCurrentProfile"; +import { + ADD_TO_COLLECTION_FOOTNOTE, + inCollections, + madeButNotAdded, + manualTitleCount, +} from "@/lib/collections/copy"; +import { PERSONAL_SCOPE, type SavableDraft } from "@/lib/collections/scope"; interface AddToCollectionDialogProps { open: boolean; onOpenChange: (open: boolean) => void; mediaItemId: string; - /** Optional label shown so the user knows what they're adding. */ + /** The title being added, named in the dialog and its messages. */ itemTitle?: string; } -interface CollectionPick { - id: string; - title: string; - source: "user" | "library"; - group: string; -} +/** How long "Added" or "Removed" stays beside a collection after its tick saves. */ +const FLASH_MS = 2000; + +type Flash = { id: string; text: "Added" | "Removed" }; /** - * Add-to-collection picker. Lists the caller's manual user collections - * plus — for admins — every manual library collection grouped by library. - * Synced/smart collections are excluded since they overwrite manual edits - * on the next sync. Selecting a row commits the add via the existing - * PUT collections/{id}/items/{itemId} endpoint (or the /admin variant). + * Add to collection: a checkbox for each of the profile's own manual + * collections (another profile's shared collection is read-only for it, and + * smart and synced collections fill themselves). A tick saves at once, and the + * initial ticks come from the list's `contains_item` marks. A New collection + * row is always there, so the dialog never dead-ends. + * + * Acting admins get the same list: titles go into a server collection from + * that collection's editor. */ export default function AddToCollectionDialog({ open, @@ -46,142 +60,394 @@ export default function AddToCollectionDialog({ mediaItemId, itemTitle, }: AddToCollectionDialogProps) { - const isAdmin = useIsActingAdmin(); - const { data: userCollections, isLoading: userLoading } = useCollections(); - const { data: libraries } = useUserLibraries(); - const addItem = useAddItemToCollection(); - const [selectedId, setSelectedId] = useState(null); - - // Admins fetch each library's collections so we can offer adding to admin - // manual collections too. Non-admins skip these queries entirely. - const libraryQueries = useQueries({ - queries: (isAdmin ? (libraries ?? []) : []).map((lib) => ({ - ...libraryCollectionsQueryOptions(lib.id), - select: getLibraryCollectionList, - })), - }); - const libraryLoading = isAdmin && libraryQueries.some((q) => q.isLoading); - - const picks = useMemo(() => { - const out: CollectionPick[] = []; - for (const c of userCollections ?? []) { - if (c.collection_type === "manual") { - out.push({ id: c.id, title: c.name, source: "user", group: "My Collections" }); - } - } - if (isAdmin && libraries) { - for (let i = 0; i < libraries.length; i++) { - const lib = libraries[i]!; - const res = libraryQueries[i]; - const collections = Array.isArray(res?.data) ? res.data : []; - for (const c of collections) { - if (c.collection_type === "manual") { - out.push({ id: c.id, title: c.title, source: "library", group: lib.name }); - } - } - } - } - return out; - }, [userCollections, libraries, libraryQueries, isAdmin]); - - const groups = useMemo(() => { - const m = new Map(); - for (const p of picks) { - if (!m.has(p.group)) m.set(p.group, []); - m.get(p.group)!.push(p); - } - return Array.from(m.entries()); - }, [picks]); - - const isLoading = userLoading || libraryLoading; - - function handleConfirm() { - if (!selectedId) return; - const pick = picks.find((p) => p.id === selectedId); - if (!pick) return; - addItem.mutate( - { collectionId: pick.id, mediaItemId, source: pick.source }, - { - onSuccess: () => { - setSelectedId(null); - onOpenChange(false); - }, - }, - ); - } - return ( - + - Add to Collection - - {itemTitle - ? `Pick a manual collection to add "${itemTitle}" to.` - : "Pick a manual collection."} + Add to a collection + + Tick a collection to add {itemTitle ?? "this title"} to it. Ticking saves right away. + {/* Mounted only while open and keyed by title, so each opening or title starts from the server's ticks. */} + onOpenChange(false)} + /> + + + ); +} -
- {isLoading ? ( -
- - Loading collections… -
- ) : groups.length === 0 ? ( -
- You don't have any manual collections yet. Create one in{" "} - Collections first. -
- ) : ( -
    - {groups.map(([group, list]) => ( -
  • -
    - {group} -
    - {list.map((p) => { - const isSelected = p.id === selectedId; - return ( - - ); - })} -
  • - ))} -
- )} -
+function AddToCollectionPanel({ + mediaItemId, + itemTitle, + onClose, +}: { + mediaItemId: string; + itemTitle: string; + onClose: () => void; +}) { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const { profile } = useCurrentProfile(); + const profileId = profile?.id; + const list = useCollectionsContaining(mediaItemId); + const [query, setQuery] = useState(""); + // Ticks changed in this dialog, ahead of the list's next read. + const [ticks, setTicks] = useState>(new Map()); + const [flash, setFlash] = useState(null); + + useEffect(() => { + if (!flash) return; + const timer = setTimeout(() => setFlash(null), FLASH_MS); + return () => clearTimeout(timer); + }, [flash]); + // Nothing is listed until the acting profile is known: ownership fails closed. + const loading = list.isLoading || !profileId; + const own = useMemo( + () => + (list.data ?? []).filter( + (collection) => + collection.collection_type === "manual" && + !PERSONAL_SCOPE.isReadOnly(PERSONAL_SCOPE.toView(collection), profileId), + ), + [list.data, profileId], + ); + const isTicked = (collection: Collection) => + ticks.get(collection.id) ?? collection.contains ?? false; + const needle = query.trim().toLowerCase(); + const shown = own.filter((collection) => collection.name.toLowerCase().includes(needle)); + const tickedCount = own.filter(isTicked).length; + + function setTick(id: string, ticked: boolean) { + setTicks((current) => new Map(current).set(id, ticked)); + } + + function changed(id: string, ticked: boolean) { + setTick(id, ticked); + setFlash({ id, text: ticked ? "Added" : "Removed" }); + } + + /** PUTs the title into a collection made here; on failure the collection is kept. */ + async function addToNew(id: string, name: string) { + try { + await putCollectionItem(PERSONAL_SCOPE, id, mediaItemId, 0); + changed(id, true); + } catch { + toast.warning(madeButNotAdded(name, itemTitle), { + action: { label: "Try again", onClick: () => void addToNew(id, name) }, + cancel: { + label: "Open it", + onClick: () => void navigate(PERSONAL_SCOPE.paths.edit(id, { focus: "titles" })), + }, + }); + } finally { + void PERSONAL_SCOPE.invalidate(queryClient, id); + } + } + + /** Makes a personal manual collection named `name`, then adds the title. False when nothing was made. */ + async function createAndAdd(name: string): Promise { + const draft: SavableDraft = { + ...PERSONAL_SCOPE.toDraft(null, { kind: "manual" }), + kind: "manual", + name, + }; + let id: string; + try { + ({ id } = await PERSONAL_SCOPE.create(draft)); + } catch (error) { + toast.error(PERSONAL_SCOPE.errorMessage(error, "Couldn't make the collection")); + return false; + } + setQuery(""); + await addToNew(id, name); + return true; + } + + if (loading) { + return ( + <> + +

+ + Loading collections… +

+ + ); + } + + if (list.isError && !list.data) { + return ( + <> + +
+

Couldn't load your collections

+ +
- - - - + + ); + } + + return ( + <> + +
+ + setQuery(event.target.value)} + /> +
+
+ {shown.length > 0 ? ( +
    + {shown.map((collection) => ( + + ))} +
+ ) : ( +

+ No collections match "{query.trim()}". +

+ )} +
+ + + + +
+
+

{ADD_TO_COLLECTION_FOOTNOTE}

+ +

{inCollections(tickedCount)}

+ +
+ + ); +} + +/** The title being added, so the dialog says what goes in. */ +function ItemChip({ title }: { title: string }) { + return ( +
+ + + + {title} +
+ ); +} + +function CollectionChoice({ + collection, + mediaItemId, + ticked, + flash, + onTick, + onSaved, +}: { + collection: Collection; + mediaItemId: string; + ticked: boolean; + flash: Flash["text"] | null; + /** Shows a tick ahead of the save, or puts it back when the save fails. */ + onTick: (id: string, ticked: boolean) => void; + onSaved: (id: string, ticked: boolean) => void; +}) { + const boxId = useId(); + const metaId = useId(); + const add = useAddItemToCollection(); + const remove = useRemoveCollectionItem(collection.id); + const saving = add.isPending || remove.isPending; + + async function toggle(next: boolean) { + // The box stays focusable while it saves, so a press during the save is ignored here. + if (saving) return; + onTick(collection.id, next); + // Settled on the promise, not `mutate`'s callbacks: a search can unmount + // this row before the save lands, and those callbacks then never run, so + // a failed save would keep its tick. The hooks report the failure. + try { + if (next) await add.mutateAsync({ collectionId: collection.id, mediaItemId }); + else await remove.mutateAsync(mediaItemId); + onSaved(collection.id, next); + } catch { + onTick(collection.id, !next); + } + } + + return ( +
  • + +
  • + ); +} + +/** A name box and "Create and add": makes a manual collection with the title in it. */ +function NewCollectionForm({ + onCreate, + labelled = false, + className, +}: { + onCreate: (name: string) => Promise; + /** Show the "New manual collection" label above the box; otherwise it is read out only. */ + labelled?: boolean; + className?: string; +}) { + const inputId = useId(); + const [name, setName] = useState(""); + const [creating, setCreating] = useState(false); + const trimmed = name.trim(); + + async function submit(event: FormEvent) { + event.preventDefault(); + if (!trimmed || creating) return; + setCreating(true); + try { + if (await onCreate(trimmed)) setName(""); + } finally { + setCreating(false); + } + } + + return ( +
    void submit(event)} className={className}> + +
    + setName(event.target.value)} + /> + +
    +
    ); } diff --git a/web/src/components/AdminLayout.test.tsx b/web/src/components/AdminLayout.test.tsx index 2a9f8ac9cb..da3578763d 100644 --- a/web/src/components/AdminLayout.test.tsx +++ b/web/src/components/AdminLayout.test.tsx @@ -49,6 +49,7 @@ function renderAdmin(initialPath = "/admin") { children: [ { index: true, element:

    Admin dashboard

    }, { path: "users", element:

    Admin users

    }, + { path: "home-rows", element:

    Admin home rows

    }, ], }, ], @@ -117,6 +118,19 @@ describe("AdminLayout shell attribute", () => { }); }); +describe("AdminLayout page title", () => { + // The tab title, the route-change announcement and the phone header all read + // this name, so a renamed route must not fall back to the generic "Admin" + // (which the phone header shows as "Dashboard"). + it("names the Home rows page in the tab and the phone header", () => { + renderAdmin("/admin/home-rows"); + + expect(document.title).toBe("Admin Home rows · Silo"); + expect(screen.getByText("Home rows")).toBeInTheDocument(); + expect(screen.queryByText("Dashboard")).not.toBeInTheDocument(); + }); +}); + describe("AdminLayout restart banner", () => { it("stays quiet while no restart is owed", () => { mocks.useAdminServerStatus.mockReturnValue({ data: { restart_required: false } }); diff --git a/web/src/components/AdminLayout.tsx b/web/src/components/AdminLayout.tsx index f3d03db6fb..e92558e959 100644 --- a/web/src/components/AdminLayout.tsx +++ b/web/src/components/AdminLayout.tsx @@ -170,11 +170,13 @@ export default function AdminLayout() { {/* `lg:pt-16` starts the content below the fixed Search/activity controls (top-5, h-9 → they end 3.5rem down). With less, a page header's right-aligned buttons sit underneath them until the capped - content column pulls away from the viewport's right edge. */} + content column pulls away from the viewport's right edge. + `--page-gutter` is published so fixed page chrome (the collection + editor's save bar) can line up with the content column. */}
    diff --git a/web/src/components/AdminSidebar.test.tsx b/web/src/components/AdminSidebar.test.tsx index e1e3c2f435..639b66563f 100644 --- a/web/src/components/AdminSidebar.test.tsx +++ b/web/src/components/AdminSidebar.test.tsx @@ -111,6 +111,14 @@ describe("AdminSidebar", () => { expect(markup).not.toContain("/admin/settings?tab="); }); + it("includes a Home rows link in the content navigation", () => { + const markup = renderSidebar(); + + expect(markup).toContain('href="/admin/home-rows"'); + expect(markup).toContain(">Home rows<"); + expect(markup).not.toContain('href="/admin/sections"'); + }); + it("hides Policy navigation when the editor capability is unavailable", () => { mockUsePolicyCapability.mockReturnValueOnce({ data: { diff --git a/web/src/components/CollectionSearchableSelect.test.tsx b/web/src/components/CollectionSearchableSelect.test.tsx deleted file mode 100644 index 7502aa8364..0000000000 --- a/web/src/components/CollectionSearchableSelect.test.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { renderToStaticMarkup } from "react-dom/server"; -import type { ReactNode } from "react"; -import { describe, expect, it, vi } from "vitest"; - -vi.mock("radix-ui", async () => { - return { - Popover: { - Root: ({ children, modal }: { children: ReactNode; modal?: boolean }) => ( -
    {children}
    - ), - Trigger: ({ children }: { children: ReactNode }) => <>{children}, - Portal: ({ children }: { children: ReactNode }) => <>{children}, - Content: ({ children }: { children: ReactNode }) =>
    {children}
    , - }, - }; -}); - -import { CollectionSearchableSelect } from "./CollectionSearchableSelect"; - -describe("CollectionSearchableSelect", () => { - it("uses a non-modal popover so wheel scrolling works inside modal sheets", () => { - const markup = renderToStaticMarkup( - {}} - />, - ); - - expect(markup).toContain('data-modal="false"'); - }); -}); diff --git a/web/src/components/CollectionSearchableSelect.tsx b/web/src/components/CollectionSearchableSelect.tsx deleted file mode 100644 index 117afc14ce..0000000000 --- a/web/src/components/CollectionSearchableSelect.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import * as React from "react"; -import { Popover as PopoverPrimitive } from "radix-ui"; -import { Check, ChevronsUpDown } from "lucide-react"; - -import { cn } from "@/lib/utils"; -import type { CollectionOption } from "@/hooks/queries/useAllUserCollections"; - -interface CollectionSearchableSelectProps { - /** The full list of available collection options. */ - options: CollectionOption[]; - /** Currently selected collection ID (empty string = nothing selected). */ - value: string; - /** Called when the user picks a collection or clears the selection. */ - onChange: (value: string) => void; - placeholder?: string; - disabled?: boolean; - /** Show a loading skeleton while options are being fetched. */ - isLoading?: boolean; -} - -export function CollectionSearchableSelect({ - options, - value, - onChange, - placeholder = "Choose collection", - disabled = false, - isLoading = false, -}: CollectionSearchableSelectProps) { - const [open, setOpen] = React.useState(false); - const [search, setSearch] = React.useState(""); - - const filtered = React.useMemo(() => { - if (!search) return options; - const lower = search.toLowerCase(); - return options.filter( - (opt) => opt.title.toLowerCase().includes(lower) || opt.group.toLowerCase().includes(lower), - ); - }, [options, search]); - - // Group filtered options by their `group` field, preserving insertion order. - const grouped = React.useMemo(() => { - const map = new Map(); - for (const opt of filtered) { - const existing = map.get(opt.group); - if (existing) { - existing.push(opt); - } else { - map.set(opt.group, [opt]); - } - } - return map; - }, [filtered]); - - const selectedOption = React.useMemo(() => options.find((o) => o.id === value), [options, value]); - - const displayText = selectedOption - ? `${selectedOption.title} (${selectedOption.group})` - : placeholder; - - return ( - - - - - - - e.preventDefault()} - > -
    - setSearch(e.target.value)} - placeholder="Search collections..." - className="border-input bg-background placeholder:text-muted-foreground flex h-8 w-full rounded-md border px-2 text-sm outline-none" - autoFocus - /> -
    - -
    - {isLoading ? ( -

    Loading...

    - ) : filtered.length === 0 ? ( -

    No collections found

    - ) : ( - <> - {/* Clear / placeholder option */} - - - {Array.from(grouped.entries()).map(([groupName, items]) => ( -
    - {/* Group header */} -
    - {groupName} -
    - {items.map((opt) => ( - - ))} -
    - ))} - - )} -
    -
    -
    -
    - ); -} diff --git a/web/src/components/CollectionTemplateGallery/CollectionTemplateCard.test.tsx b/web/src/components/CollectionTemplateGallery/CollectionTemplateCard.test.tsx index 23872b03aa..58cd5dcc69 100644 --- a/web/src/components/CollectionTemplateGallery/CollectionTemplateCard.test.tsx +++ b/web/src/components/CollectionTemplateGallery/CollectionTemplateCard.test.tsx @@ -1,9 +1,11 @@ -import { renderToStaticMarkup } from "react-dom/server"; +import { render, screen } from "@testing-library/react"; +import { RadioGroup } from "radix-ui"; import { describe, expect, it } from "vitest"; -import { CollectionTemplateCard } from "./CollectionTemplateCard"; import type { CollectionTemplate } from "@/lib/collectionTemplates"; +import { CollectionTemplateCard } from "./CollectionTemplateCard"; + const baseTemplate: CollectionTemplate = { id: "tmdb_trending_movies_week", title: "Trending Movies This Week", @@ -18,51 +20,36 @@ const baseTemplate: CollectionTemplate = { tmdb: { preset: "trending", media_type: "movie", time_window: "week" }, }; +function show(template: CollectionTemplate, value = "") { + render( + + + , + ); + return screen.getByRole("radio", { name: template.title }); +} + describe("CollectionTemplateCard", () => { - it("shows the template title, description, source badge, and media kind", () => { - const markup = renderToStaticMarkup( - {}} - />, + it("is a radio named by the template, described by its source, kind, size and schedule", () => { + const radio = show({ + ...baseTemplate, + poster_path: "/images/collection-templates/trending.jpg", + }); + expect(radio).toHaveAccessibleDescription("TMDB Movies · 50 titles · syncs daily"); + expect(radio).not.toBeChecked(); + expect(radio.querySelector("img")).toHaveAttribute( + "src", + "/images/collection-templates/trending.jpg", ); - - expect(markup).toContain("Trending Movies This Week"); - expect(markup).toContain("Top trending movies on TMDB"); - expect(markup).toContain("TMDB"); - expect(markup).toContain("Movies"); - expect(markup).toContain("syncs daily"); - expect(markup).not.toContain(" { - const markup = renderToStaticMarkup( - {}} - />, - ); - - expect(markup).toContain("Profile"); - expect(markup).toContain("Trakt"); + it("shows as checked when the draft follows it", () => { + expect(show(baseTemplate, baseTemplate.id)).toBeChecked(); }); - it("falls back gracefully when no schedule is configured", () => { - const markup = renderToStaticMarkup( - {}} - />, - ); - - expect(markup).not.toContain("syncs"); + it("leaves out a schedule it doesn't have, and uses its icon without a poster", () => { + const radio = show({ ...baseTemplate, default_sync_schedule: undefined, default_limit: 0 }); + expect(radio).toHaveAccessibleDescription("TMDB Movies"); + expect(radio).toHaveTextContent("🎬"); }); }); diff --git a/web/src/components/CollectionTemplateGallery/CollectionTemplateCard.tsx b/web/src/components/CollectionTemplateGallery/CollectionTemplateCard.tsx index 3ba14309cd..b26248340d 100644 --- a/web/src/components/CollectionTemplateGallery/CollectionTemplateCard.tsx +++ b/web/src/components/CollectionTemplateGallery/CollectionTemplateCard.tsx @@ -1,74 +1,16 @@ -import { Badge } from "@/components/ui/badge"; +import { useId, type ReactNode } from "react"; +import { RadioGroup as RadioGroupPrimitive } from "radix-ui"; + +import { RadioDot } from "@/components/ui/radio-group"; import { mediaKindLabel, type CollectionTemplate } from "@/lib/collectionTemplates"; -const SOURCE_LABEL: Record = { - tmdb: "TMDB", - trakt: "Trakt", +const SOURCE_TAG: Partial> = { mdblist: "MDBList", - tmdb_discover: "TMDB Discover", - tmdb_collection: "TMDB Franchise", - tmdb_list: "TMDB List", + tmdb: "TMDB", + tmdb_list: "TMDB", }; -interface Props { - template: CollectionTemplate; - onPick: (template: CollectionTemplate) => void; -} - -export function CollectionTemplateCard({ template, onPick }: Props) { - return ( - - ); -} - -function TemplateBadges({ template }: { template: CollectionTemplate }) { - return ( - <> - - {SOURCE_LABEL[template.source]} - - {template.requires_profile ? ( - - Profile - - ) : null} - - ); -} - +/** "daily", "weekly": how often a template's cron syncs, for its meta line. */ function scheduleDescription(cron: string): string { const [, hour, dom, month, dow, extra] = cron.trim().split(/\s+/); if (!hour || !dom || !month || !dow || extra !== undefined) return "on schedule"; @@ -80,3 +22,90 @@ function scheduleDescription(cron: string): string { if (dom === "*" && month === "*") return "weekly"; return "on schedule"; } + +/** + * One list in the Synced list step: a radio row with a small poster, the + * name, a source tag and a meta line. Must sit inside a radio group. + */ +export function PickRow({ + value, + title, + tag, + poster, + meta, +}: { + value: string; + title: string; + tag?: string; + poster?: ReactNode; + meta: string; +}) { + const id = useId(); + return ( + + + + {poster} + + + + + {title} + + {tag ? ( + + {tag} + + ) : null} + + + {meta} + + + + ); +} + +/** A ready-made pick: a template that names its list. */ +export function CollectionTemplateCard({ template }: { template: CollectionTemplate }) { + const meta = [ + mediaKindLabel(template.media_kind), + template.default_limit ? `${template.default_limit} titles` : null, + template.default_sync_schedule + ? `syncs ${scheduleDescription(template.default_sync_schedule)}` + : null, + ] + .filter(Boolean) + .join(" · "); + return ( + + ) : ( + template.icon + ) + } + /> + ); +} diff --git a/web/src/components/CollectionTemplateGallery/CollectionTemplateConfigForm.tsx b/web/src/components/CollectionTemplateGallery/CollectionTemplateConfigForm.tsx deleted file mode 100644 index ac826d89fb..0000000000 --- a/web/src/components/CollectionTemplateGallery/CollectionTemplateConfigForm.tsx +++ /dev/null @@ -1,574 +0,0 @@ -import { useAdminCollectionCapabilities } from "@/hooks/queries/admin/collections"; -import { useState } from "react"; -import type { FormEvent } from "react"; - -import { - useImportMDBListCollection, - useImportTMDBCollection, - useImportTMDBListCollection, - useImportTraktCollection, -} from "@/hooks/queries/admin/collections"; -import { useProfiles } from "@/hooks/queries/profiles"; -import type { Library } from "@/api/types"; -import { - COLLECTION_MAX_ITEMS, - libraryEligibilityForMediaKind, - mediaKindLabel, -} from "@/lib/collectionTemplates"; -import type { CollectionTemplate, LibraryEligibility } from "@/lib/collectionTemplates"; -import { COLLECTION_SOURCE_ORDER, selectValueToSortConfig } from "@/lib/collectionSortConfig"; -import { - CollectionLibraryPicker, - parseOptionalPositiveInteger, -} from "@/pages/adminCollectionsShared"; - -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { CollectionDefaultSortField } from "@/components/collections/CollectionDefaultSortField"; -import { SyncScheduleField } from "@/components/collections/SyncScheduleField"; -import { TMDBListURLField } from "@/components/collections/TMDBListURLField"; -import { isValidTMDBListURL } from "@/lib/tmdbList"; - -import { MDBListBrowser } from "./MDBListBrowser"; -import { TemplatePosterField, type TemplatePosterMode } from "./TemplatePosterField"; - -interface Props { - template: CollectionTemplate; - libraries: Library[]; - initialLibraryId: number | null; - onCancel: () => void; - onCreated: () => void; -} - -function isEligibleTemplateLibrary( - library: Pick, - eligibility: LibraryEligibility, -): boolean { - const kinds = eligibility.kinds; - if (!kinds || kinds.length === 0) return true; - if (!library.type) return true; - if (library.type === "mixed") return true; - return kinds.includes(library.type); -} - -function initialTemplateLibraryIds( - libraries: Library[], - initialLibraryId: number | null, - eligibility: LibraryEligibility, -): number[] { - const eligibleLibraries = libraries.filter((library) => - isEligibleTemplateLibrary(library, eligibility), - ); - if (eligibleLibraries.length === 0) return []; - - if (initialLibraryId) { - const initialLibrary = eligibleLibraries.find((library) => library.id === initialLibraryId); - if (initialLibrary) return [initialLibrary.id]; - } - - const [firstEligibleLibrary] = eligibleLibraries; - return firstEligibleLibrary ? [firstEligibleLibrary.id] : []; -} - -export function CollectionTemplateConfigForm({ - template, - libraries, - initialLibraryId, - onCancel, - onCreated, -}: Props) { - const { data: capabilities } = useAdminCollectionCapabilities(); - const tmdbMutation = useImportTMDBCollection(); - const traktMutation = useImportTraktCollection(); - const mdblistMutation = useImportMDBListCollection(); - const tmdbListMutation = useImportTMDBListCollection(); - const { data: profiles = [] } = useProfiles(); - const eligibility = libraryEligibilityForMediaKind(template.media_kind); - - const [libraryIds, setLibraryIds] = useState(() => - initialTemplateLibraryIds(libraries, initialLibraryId, eligibility), - ); - - const [title, setTitle] = useState(template.title); - const [description, setDescription] = useState(template.description); - const [limit, setLimit] = useState(template.default_limit ? String(template.default_limit) : ""); - const [syncSchedule, setSyncSchedule] = useState(template.default_sync_schedule ?? ""); - const [featured, setFeatured] = useState(template.featured ?? true); - const defaultProfileId = template.requires_profile ? (profiles[0]?.id ?? "") : ""; - const [profileId, setProfileId] = useState(defaultProfileId); - const [mdblistUrl, setMdblistUrl] = useState(template.mdblist?.url ?? ""); - const [tmdbListUrl, setTmdbListUrl] = useState(template.tmdb_list?.url ?? ""); - const [posterMode, setPosterMode] = useState(() => - template.poster_path ? "default" : "custom", - ); - const [customPosterUrl, setCustomPosterUrl] = useState(""); - const [defaultSort, setDefaultSort] = useState(COLLECTION_SOURCE_ORDER); - - // Discover- and Collection-source templates are bundle-only: the spec is - // backend-driven and can't be edited inline. Render a read-only summary so - // admins know to apply them via Template Bundles. The early returns MUST - // sit AFTER every useState above so React's rules-of-hooks lint stays - // green — the unused state slots are cheap and isolate this branch from - // the editable-form path. - if (template.source === "tmdb_collection") { - return ; - } - if (template.source === "tmdb_discover") { - return ; - } - - // When profiles load after initial render and the template needs one but - // no value has been chosen yet, settle on the first profile. - if (template.requires_profile && profileId === "" && profiles[0]?.id) { - setProfileId(profiles[0].id); - } - - const parsedLimit = parseOptionalPositiveInteger(limit); - const limitInvalid = limit.trim().length > 0 && parsedLimit === undefined; - const isPending = - tmdbMutation.isPending || - traktMutation.isPending || - mdblistMutation.isPending || - tmdbListMutation.isPending; - const missingLibrary = libraryIds.length === 0; - const missingProfile = template.requires_profile && profileId === ""; - const missingMDBListURL = template.source === "mdblist" && mdblistUrl.trim().length === 0; - const invalidTMDBListURL = template.source === "tmdb_list" && !isValidTMDBListURL(tmdbListUrl); - - const submitDisabled = - isPending || - missingLibrary || - missingProfile || - missingMDBListURL || - invalidTMDBListURL || - limitInvalid; - - function handleSubmit(event: FormEvent) { - event.preventDefault(); - if (submitDisabled) return; - - const sharedFields = { - library_ids: libraryIds, - title: title.trim() || template.title, - description: description.trim() || undefined, - featured, - sync_schedule: syncSchedule.trim() || undefined, - limit: parsedLimit, - sort_config: selectValueToSortConfig(defaultSort), - ...(!capabilities?.artwork - ? {} - : posterMode === "custom" - ? { poster_source_url: customPosterUrl.trim() || undefined } - : { poster_url: template.poster_path || undefined }), - }; - - if (template.source === "tmdb" && template.tmdb) { - tmdbMutation.mutate( - { - body: { - ...sharedFields, - preset: template.tmdb.preset, - media_type: template.tmdb.media_type, - time_window: template.tmdb.time_window, - }, - }, - { onSuccess: onCreated }, - ); - return; - } - - if (template.source === "trakt" && template.trakt) { - traktMutation.mutate( - { - body: { - ...sharedFields, - preset: template.trakt.preset, - media_type: template.trakt.media_type, - profile_id: template.requires_profile ? profileId : undefined, - }, - }, - { onSuccess: onCreated }, - ); - return; - } - - if (template.source === "mdblist" && template.mdblist) { - mdblistMutation.mutate( - { - body: { - ...sharedFields, - url: mdblistUrl.trim(), - }, - }, - { onSuccess: onCreated }, - ); - return; - } - - if (template.source === "tmdb_list") { - tmdbListMutation.mutate( - { - body: { - ...sharedFields, - url: tmdbListUrl.trim(), - }, - }, - { onSuccess: onCreated }, - ); - return; - } - } - - return ( -
    -
    -
    - {template.icon} -
    -
    -
    - {template.title} - - {template.source} - - - {mediaKindLabel(template.media_kind)} - -
    -

    {template.description}

    -
    -
    - -
    -
    - - -
    -
    - - setTitle(event.target.value)} - required - /> -
    -
    - -
    - - setDescription(event.target.value)} - placeholder="Optional summary shown to viewers" - /> -
    - - {template.source === "mdblist" ? ( -
    - { - setMdblistUrl(jsonURL); - if (!title.trim() || title.trim() === template.title) { - setTitle(list.name); - } - }} - /> -
    - - setMdblistUrl(event.target.value)} - placeholder="https://mdblist.com/lists/user/slug" - required - /> -

    - Pick a list above or paste any public MDBList list URL (with or without{" "} - /json). Items resolve via TMDB/IMDb/TVDB IDs. -

    -
    -
    - ) : null} - - {template.source === "tmdb_list" ? ( - - ) : null} - - {template.requires_profile ? ( -
    - - -

    - This template uses the chosen profile's connected Trakt account from Watch Providers - settings. -

    -
    - ) : null} - - {capabilities?.artwork && ( - - )} - -
    -
    - - setLimit(event.target.value)} - placeholder={template.default_limit ? String(template.default_limit) : "No limit"} - /> -
    -
    - -
    - Surface in hero shelves - -
    -
    -
    - - - - - -
    - - -
    - - ); -} - -interface TMDBCollectionTemplateSummaryProps { - template: CollectionTemplate; - onCancel: () => void; -} - -// TMDBCollectionTemplateSummary is the read-only view shown when an admin -// opens a `tmdb_collection` template directly from the gallery. The spec -// (TMDB collection ID, sort order, sync schedule) ships from the backend -// catalog and isn't user-editable at apply-time — the official flow is to -// apply via Template Bundles, where bulk creation, dedupe by management key, -// and featured-section wiring are all handled in one shot. -function TMDBCollectionTemplateSummary({ template, onCancel }: TMDBCollectionTemplateSummaryProps) { - const collectionId = template.tmdb_collection?.collection_id ?? 0; - const isPlaceholder = collectionId === 0; - - return ( -
    -
    -
    - {template.icon} -
    -
    -
    - {template.title} - - {template.source} - - - {mediaKindLabel(template.media_kind)} - -
    -

    {template.description}

    -
    -
    - -
    -
    -
    TMDB Collection ID
    -
    - {isPlaceholder ? "(unset — admin fills in at apply)" : collectionId} -
    -
    - {typeof template.default_sort_order === "number" ? ( -
    -
    Default Sort Order
    -
    {template.default_sort_order}
    -
    - ) : null} -
    - -

    - TMDB franchise templates are bundle-driven. Apply them through Template Bundles so the - management key, library scoping, and sync schedule are wired up consistently across - libraries. - {isPlaceholder ? ( - <> - {" "} - This template is a placeholder; after applying, edit the collection's source_config and - replace collection_id: 0 with the real TMDB collection ID before the first - sync. - - ) : null} -

    - -
    - - -
    -
    - ); -} - -interface TMDBDiscoverTemplateSummaryProps { - template: CollectionTemplate; - onCancel: () => void; -} - -// summarizeDiscoverSpec produces a one-line human description of the discover -// filter set so admins can see at a glance what the template will fetch. -function summarizeDiscoverSpec(spec: NonNullable): string { - const parts: string[] = []; - parts.push(spec.media_type === "tv" ? "TV" : "movies"); - parts.push(`sorted by ${spec.sort_by}`); - if (spec.with_genres?.length) { - parts.push(`genres ${spec.with_genres.join(", ")}`); - } - if (spec.vote_count_gte) { - parts.push(`votes ≥ ${spec.vote_count_gte}`); - } - if (spec.vote_average_gte) { - parts.push(`rating ≥ ${spec.vote_average_gte}`); - } - if (spec.release_date_gte || spec.release_date_lte) { - const lo = spec.release_date_gte ?? "…"; - const hi = spec.release_date_lte ?? "…"; - parts.push(`released ${lo}–${hi}`); - } - if (spec.certifications?.length) { - parts.push(`certs ${spec.certifications.join("/")}`); - } - if (spec.certification_lte) { - parts.push(`cert ≤ ${spec.certification_lte}`); - } - if (spec.original_language) { - parts.push(`lang ${spec.original_language}`); - } - return parts.join(" · "); -} - -// TMDBDiscoverTemplateSummary mirrors TMDBCollectionTemplateSummary: TMDB -// discover templates ship as backend-driven blueprints (genre matrices etc.) -// and apply through Template Bundles, not the per-template create form. -function TMDBDiscoverTemplateSummary({ template, onCancel }: TMDBDiscoverTemplateSummaryProps) { - const spec = template.tmdb_discover; - - return ( -
    -
    -
    - {template.icon} -
    -
    -
    - {template.title} - - {template.source} - - - {mediaKindLabel(template.media_kind)} - -
    -

    {template.description}

    -
    -
    - - {spec ? ( -
    -
    -
    Filter Summary
    -
    {summarizeDiscoverSpec(spec)}
    -
    - {typeof template.default_sort_order === "number" ? ( -
    -
    Default Sort Order
    -
    {template.default_sort_order}
    -
    - ) : null} -
    - ) : null} - -

    - Discover templates are applied via Template Bundles. The filter set ships from the backend - catalog; apply through a bundle so the management key, library scoping, and sync schedule - are wired up consistently across libraries. -

    - -
    - - -
    -
    - ); -} diff --git a/web/src/components/CollectionTemplateGallery/CollectionTemplateGallery.test.tsx b/web/src/components/CollectionTemplateGallery/CollectionTemplateGallery.test.tsx deleted file mode 100644 index 1257a0c461..0000000000 --- a/web/src/components/CollectionTemplateGallery/CollectionTemplateGallery.test.tsx +++ /dev/null @@ -1,518 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import type { Library } from "@/api/types"; -import { CollectionTemplateGallery } from "./CollectionTemplateGallery"; - -// Radix Select reads element sizes via ResizeObserver, which jsdom does not -// provide. A no-op polyfill is enough to render the dialog content. -class ResizeObserverStub { - observe() {} - unobserve() {} - disconnect() {} -} -if (typeof globalThis.ResizeObserver === "undefined") { - (globalThis as unknown as { ResizeObserver: typeof ResizeObserverStub }).ResizeObserver = - ResizeObserverStub; -} -if (typeof window !== "undefined" && !window.HTMLElement.prototype.hasPointerCapture) { - window.HTMLElement.prototype.hasPointerCapture = () => false; - window.HTMLElement.prototype.scrollIntoView = () => {}; -} - -const apiClientMocks = vi.hoisted(() => { - class ApiClientErrorMock extends Error { - status: number; - - constructor(message: string, status: number) { - super(message); - this.status = status; - } - } - - return { - ApiClientErrorMock, - fetchMock: vi.fn(), - }; -}); - -const { fetchMock } = apiClientMocks; - -vi.mock("@/api/client", () => ({ - ApiClientError: apiClientMocks.ApiClientErrorMock, - api: (path: string, options?: unknown) => fetchMock(path, options), -})); - -vi.mock("@/api/v2/request", async () => { - const actual = await vi.importActual("@/api/v2/request"); - return { - ...actual, - v2: (operation: string, options?: unknown) => - operation === "GET /api/v2/admin/collections/capabilities" - ? Promise.resolve({ artwork: true, imports: true, groups: true, item_reorder: true }) - : fetchMock(operation, options), - }; -}); - -vi.mock("sonner", () => ({ - toast: { success: vi.fn(), error: vi.fn() }, -})); - -vi.mock("@/hooks/queries/profiles", () => ({ - useProfiles: () => ({ data: [] as Array<{ id: string; name: string }> }), -})); - -vi.mock("@/hooks/queries/collectionSurfaceRefresh", () => ({ - invalidateAdminCollectionQueries: vi.fn(), - invalidateUserCollectionQueries: vi.fn(), -})); - -vi.mock("@/hooks/queries/libraries", async () => { - const actual = await vi.importActual( - "@/hooks/queries/libraries", - ); - return { ...actual, useUserLibraries: () => ({ data: [] }) }; -}); - -const catalogResponse = { - categories: [ - { - category: "trending", - label: "Trending", - templates: [ - { - id: "tmdb_trending_movies_week", - title: "Trending Movies This Week", - description: "Top trending movies on TMDB.", - icon: "🎬", - category: "trending", - source: "tmdb", - media_kind: "movie", - default_limit: 50, - tmdb: { preset: "trending", media_type: "movie", time_window: "week" }, - }, - ], - }, - { - category: "popular", - label: "Popular", - templates: [ - { - id: "trakt_popular_shows", - title: "Trakt Popular Shows", - description: "Trakt's most-watched shows.", - icon: "🌟", - category: "popular", - source: "trakt", - media_kind: "tv", - trakt: { preset: "popular", media_type: "tv" }, - }, - ], - }, - { - category: "custom", - label: "Custom", - templates: [ - { - id: "tmdb_list_custom", - title: "Custom TMDB List", - description: "Paste any public TMDB list URL to seed a synced collection.", - icon: "🎞️", - category: "custom", - source: "tmdb_list", - media_kind: "mixed", - default_limit: 100, - tmdb_list: { url: "" }, - }, - ], - }, - ], -}; - -const bundlesResponse = { - bundles: [ - { - id: "core_defaults", - title: "Core Defaults", - description: "A focused starter set of movie and TV collections.", - template_ids: ["tmdb_trending_movies_week", "trakt_popular_shows"], - }, - ], -}; - -const libraries: Library[] = [ - { id: 1, name: "Movies", type: "movies" } as unknown as Library, - { id: 2, name: "TV Shows", type: "series" } as unknown as Library, -]; - -function renderGallery() { - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - return render( - - {}} - libraries={libraries} - initialLibraryId={1} - /> - , - ); -} - -describe("CollectionTemplateGallery", () => { - beforeEach(() => { - fetchMock.mockReset(); - fetchMock.mockImplementation((path: string) => { - if (path === "GET /api/v2/admin/collections/templates") - return Promise.resolve(catalogResponse); - if (path === "GET /api/v2/admin/collections/template-bundles") - return Promise.resolve(bundlesResponse); - throw new Error(`unexpected path: ${path}`); - }); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it("filters templates by search across title and description", async () => { - const user = userEvent.setup(); - renderGallery(); - - await waitFor(() => { - expect(screen.getByText("Trending Movies This Week")).toBeInTheDocument(); - }); - - await user.type(screen.getByPlaceholderText("Search templates"), "trakt"); - expect(screen.queryByText("Trending Movies This Week")).not.toBeInTheDocument(); - expect(screen.getByText("Trakt Popular Shows")).toBeInTheDocument(); - }); - - it("dispatches to the TMDB import endpoint when submitting a TMDB template", async () => { - const user = userEvent.setup(); - renderGallery(); - - await waitFor(() => { - expect(screen.getByText("Trending Movies This Week")).toBeInTheDocument(); - }); - - expect(screen.getByText("Trakt Popular Shows")).toBeInTheDocument(); - expect(screen.getAllByText("Trending").length).toBeGreaterThan(0); - expect(screen.getAllByText("Popular").length).toBeGreaterThan(0); - expect(screen.getByText("Core Defaults")).toBeInTheDocument(); - - fetchMock.mockImplementation((path: string) => { - if (path === "GET /api/v2/admin/collections/templates") - return Promise.resolve(catalogResponse); - if (path === "GET /api/v2/admin/collections/template-bundles") - return Promise.resolve(bundlesResponse); - if (path === "POST /api/v2/admin/collections/import/tmdb") { - return Promise.resolve({ - collection: { id: "x", library_id: "1", library_ids: ["1"], query_definition: {} }, - }); - } - throw new Error(`unexpected path: ${path}`); - }); - - await user.click(screen.getByText("Trending Movies This Week")); - expect(screen.getByRole("button", { name: /Create Collection/i })).toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: /Create Collection/i })); - - await waitFor(() => { - expect(fetchMock).toHaveBeenCalledWith( - "POST /api/v2/admin/collections/import/tmdb", - expect.any(Object), - ); - }); - }); - - it("dispatches to the Trakt import endpoint when submitting a Trakt template", async () => { - const user = userEvent.setup(); - renderGallery(); - - await waitFor(() => { - expect(screen.getByText("Trakt Popular Shows")).toBeInTheDocument(); - }); - - fetchMock.mockImplementation((path: string) => { - if (path === "GET /api/v2/admin/collections/templates") - return Promise.resolve(catalogResponse); - if (path === "GET /api/v2/admin/collections/template-bundles") - return Promise.resolve(bundlesResponse); - if (path === "POST /api/v2/admin/collections/import/trakt") { - return Promise.resolve({ collection: { id: "y" } }); - } - throw new Error(`unexpected path: ${path}`); - }); - - await user.click(screen.getByText("Trakt Popular Shows")); - expect(screen.getByRole("button", { name: /TV Shows/i })).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: /^Movies$/i })).not.toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: /Create Collection/i })); - - await waitFor(() => { - expect(fetchMock).toHaveBeenCalledWith( - "POST /api/v2/admin/collections/import/trakt", - expect.any(Object), - ); - }); - }); - - it("asks for a TMDB list URL and imports a Custom TMDB List template", async () => { - const user = userEvent.setup(); - renderGallery(); - - await waitFor(() => { - expect(screen.getByText("Custom TMDB List")).toBeInTheDocument(); - }); - - fetchMock.mockImplementation((path: string) => { - if (path === "GET /api/v2/admin/collections/templates") - return Promise.resolve(catalogResponse); - if (path === "GET /api/v2/admin/collections/template-bundles") - return Promise.resolve(bundlesResponse); - if (path === "POST /api/v2/admin/collections/import/tmdb-list") { - return Promise.resolve({ collection: { id: "z" } }); - } - throw new Error(`unexpected path: ${path}`); - }); - - await user.click(screen.getByText("Custom TMDB List")); - const submit = screen.getByRole("button", { name: /Create Collection/i }); - expect(submit).toBeDisabled(); - - const url = screen.getByLabelText("TMDB list URL"); - await user.type(url, "https://www.themoviedb.org/movie/550"); - expect(url).toHaveAttribute("aria-invalid", "true"); - expect(submit).toBeDisabled(); - - await user.clear(url); - await user.type(url, "https://www.themoviedb.org/list/310-my-movie-list"); - expect(url).not.toHaveAttribute("aria-invalid"); - await user.click(submit); - - await waitFor(() => { - expect(fetchMock).toHaveBeenCalledWith( - "POST /api/v2/admin/collections/import/tmdb-list", - expect.objectContaining({ - body: expect.objectContaining({ - url: "https://www.themoviedb.org/list/310-my-movie-list", - library_ids: ["1"], - }), - }), - ); - }); - }); - - it("previews the core defaults bundle", async () => { - const user = userEvent.setup(); - renderGallery(); - - await waitFor(() => { - expect(screen.getByText("Core Defaults")).toBeInTheDocument(); - }); - - fetchMock.mockImplementation((path: string) => { - if (path === "GET /api/v2/admin/collections/templates") - return Promise.resolve(catalogResponse); - if (path === "GET /api/v2/admin/collections/template-bundles") - return Promise.resolve(bundlesResponse); - if (path === "POST /api/v2/admin/collections/template-bundles/{bundle_id}/apply") { - return Promise.resolve({ - bundle_id: "core_defaults", - dry_run: true, - created: [ - { - template_id: "tmdb_trending_movies_week", - template_title: "Trending Movies This Week", - library_id: "1", - library_name: "Movies", - reason: "would_create", - }, - ], - skipped: [], - failed: [], - deleted: [], - delete_skipped: [], - delete_failed: [], - sync_queued: [], - featured: [], - featured_failed: [], - }); - } - throw new Error(`unexpected path: ${path}`); - }); - - await user.click(screen.getByText("Core Defaults")); - expect(screen.getByText("Featured Sections")).toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: /^Preview$/i })); - - await waitFor(() => { - expect(screen.getByText(/Would create 1; skipped 0; failed 0/i)).toBeInTheDocument(); - }); - const applyCall = fetchMock.mock.calls.find( - ([path]) => path === "POST /api/v2/admin/collections/template-bundles/{bundle_id}/apply", - ); - expect(applyCall?.[1]?.body).toMatchObject({ - featured: { - home: { library_id: "1", template_id: "tmdb_trending_movies_week" }, - libraries: { "1": "tmdb_trending_movies_week" }, - }, - }); - }); - - it("previews deleting existing server collections before applying defaults", async () => { - const user = userEvent.setup(); - renderGallery(); - - await waitFor(() => { - expect(screen.getByText("Core Defaults")).toBeInTheDocument(); - }); - - fetchMock.mockImplementation((path: string) => { - if (path === "GET /api/v2/admin/collections/templates") - return Promise.resolve(catalogResponse); - if (path === "GET /api/v2/admin/collections/template-bundles") - return Promise.resolve(bundlesResponse); - if (path === "POST /api/v2/admin/collections/template-bundles/{bundle_id}/apply") { - return Promise.resolve({ - bundle_id: "core_defaults", - dry_run: true, - delete_existing: true, - deleted: [ - { - library_id: "1", - library_name: "Movies", - collection_id: "lc_old", - collection_title: "Old Movies", - reason: "would_delete", - }, - ], - delete_skipped: [], - delete_failed: [], - created: [], - skipped: [], - failed: [], - sync_queued: [], - featured: [], - featured_failed: [], - }); - } - throw new Error(`unexpected path: ${path}`); - }); - - await user.click(screen.getByText("Core Defaults")); - await user.click(screen.getByText("Delete Existing Server Collections")); - await user.click(screen.getByRole("button", { name: /^Preview$/i })); - - await waitFor(() => { - expect( - screen.getByText(/Would delete 1; delete skipped 0; delete failed 0/i), - ).toBeInTheDocument(); - }); - const applyCall = fetchMock.mock.calls.find( - ([path]) => path === "POST /api/v2/admin/collections/template-bundles/{bundle_id}/apply", - ); - expect(applyCall?.[1]?.body).toMatchObject({ - dry_run: true, - delete_existing: true, - library_ids: ["1"], - }); - }); - - it("queues the core defaults bundle apply job", async () => { - const user = userEvent.setup(); - renderGallery(); - - await waitFor(() => { - expect(screen.getByText("Core Defaults")).toBeInTheDocument(); - }); - - fetchMock.mockImplementation((path: string) => { - if (path === "GET /api/v2/admin/collections/templates") - return Promise.resolve(catalogResponse); - if (path === "GET /api/v2/admin/collections/template-bundles") - return Promise.resolve(bundlesResponse); - if (path === "POST /api/v2/admin/collections/template-bundles/{bundle_id}/apply-job") { - return Promise.resolve({ - id: "job-1", - kind: "template_bundle_apply", - state: "queued", - terminal: false, - cancelable: false, - created_at: "2026-09-05T00:00:00Z", - }); - } - throw new Error(`unexpected path: ${path}`); - }); - - await user.click(screen.getByText("Core Defaults")); - await user.click(screen.getByRole("button", { name: /Apply Defaults/i })); - - await waitFor(() => { - expect(fetchMock).toHaveBeenCalledWith( - "POST /api/v2/admin/collections/template-bundles/{bundle_id}/apply-job", - expect.objectContaining({ - path: { bundle_id: "core_defaults" }, - body: expect.objectContaining({ library_ids: ["1"] }), - }), - ); - }); - }); -}); - -// Personal mode lists what GET /collections/templates returns, which the server -// limits to sources a personal collection can import (#1640). -describe("CollectionTemplateGallery in user mode", () => { - const userCatalog = { - categories: [catalogResponse.categories[0], catalogResponse.categories[2]], - }; - - function renderUserGallery() { - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - return render( - - {}} /> - , - ); - } - - beforeEach(() => { - fetchMock.mockReset(); - fetchMock.mockImplementation((path: string) => { - if (path === "GET /api/v2/collections/templates") return Promise.resolve(userCatalog); - if (path === "POST /api/v2/collections/import/tmdb") { - return Promise.resolve({ collection: { id: "personal-1" } }); - } - throw new Error(`unexpected path: ${path}`); - }); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it("creates a personal collection from a TMDB template", async () => { - const user = userEvent.setup(); - renderUserGallery(); - - await waitFor(() => { - expect(screen.getByText("Trending Movies This Week")).toBeInTheDocument(); - }); - expect(screen.queryByText("Core Defaults")).not.toBeInTheDocument(); - - await user.click(screen.getByText("Trending Movies This Week")); - await user.click(screen.getByRole("button", { name: /Create Collection/i })); - - await waitFor(() => { - expect(fetchMock).toHaveBeenCalledWith( - "POST /api/v2/collections/import/tmdb", - expect.objectContaining({ - body: expect.objectContaining({ preset: "trending", media_type: "movie" }), - }), - ); - }); - }); -}); diff --git a/web/src/components/CollectionTemplateGallery/CollectionTemplateGallery.tsx b/web/src/components/CollectionTemplateGallery/CollectionTemplateGallery.tsx deleted file mode 100644 index 7a2d733505..0000000000 --- a/web/src/components/CollectionTemplateGallery/CollectionTemplateGallery.tsx +++ /dev/null @@ -1,713 +0,0 @@ -import { useMemo, useState } from "react"; -import { ChevronLeft, Layers3, Search } from "lucide-react"; - -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Switch } from "@/components/ui/switch"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import type { Library } from "@/api/types"; -import { - libraryEligibilityForMediaKind, - useCollectionTemplateBundles, - useCollectionTemplates, - type ApplyCollectionTemplateBundleFeaturedRequest, - type ApplyCollectionTemplateBundleResponse, - type CollectionTemplate, - type CollectionTemplateBundle, - type CollectionTemplateCategory, - type CollectionTemplateGroup, -} from "@/lib/collectionTemplates"; -import LibraryMultiSelect from "@/components/LibraryMultiSelect"; -import { - useApplyCollectionTemplateBundle, - useQueueCollectionTemplateBundleApply, -} from "@/hooks/queries/admin/collections"; -import { useUserCollectionTemplates } from "@/hooks/queries/userCollectionImports"; -import { CollectionTemplateCard } from "./CollectionTemplateCard"; -import { CollectionTemplateConfigForm } from "./CollectionTemplateConfigForm"; -import { UserCollectionTemplateConfigForm } from "./UserCollectionTemplateConfigForm"; - -type ActiveCategory = CollectionTemplateCategory | "all"; - -interface AdminProps { - mode?: "admin"; - open: boolean; - onOpenChange: (open: boolean) => void; - libraries: Library[]; - initialLibraryId: number | null; - /** Optional callback fired once a template-driven import succeeds. */ - onCreated?: () => void; -} - -interface UserProps { - mode: "user"; - open: boolean; - onOpenChange: (open: boolean) => void; - /** Optional callback fired once a template-driven import succeeds. */ - onCreated?: () => void; -} - -type Props = AdminProps | UserProps; - -export function CollectionTemplateGallery(props: Props) { - const isUserMode = props.mode === "user"; - const adminTemplates = useCollectionTemplates(!isUserMode && props.open); - const adminBundles = useCollectionTemplateBundles(!isUserMode && props.open); - const userTemplates = useUserCollectionTemplates(isUserMode && props.open); - const { data, isLoading, error } = isUserMode ? userTemplates : adminTemplates; - const galleryError = !isUserMode && adminBundles.error ? adminBundles.error : error; - const { open, onOpenChange, onCreated } = props; - const [search, setSearch] = useState(""); - const [activeCategory, setActiveCategory] = useState("all"); - const [picked, setPicked] = useState(null); - const [pickedBundle, setPickedBundle] = useState(null); - - const handleOpenChange = (next: boolean) => { - // Reset internal state on close so the next open starts at the gallery - // root rather than resuming a stale picked-template view. - if (!next) { - setSearch(""); - setActiveCategory("all"); - setPicked(null); - setPickedBundle(null); - } - onOpenChange(next); - }; - - const groups: CollectionTemplateGroup[] = useMemo(() => data?.categories ?? [], [data]); - const bundles = !isUserMode ? (adminBundles.data?.bundles ?? []) : []; - - const filtered = useMemo(() => { - const term = search.trim().toLowerCase(); - return groups - .filter((group) => activeCategory === "all" || group.category === activeCategory) - .map((group) => ({ - ...group, - templates: group.templates.filter((tmpl) => { - if (!term) return true; - return ( - tmpl.title.toLowerCase().includes(term) || - tmpl.description.toLowerCase().includes(term) || - (tmpl.tags ?? []).some((tag) => tag.toLowerCase().includes(term)) - ); - }), - })) - .filter((group) => group.templates.length > 0); - }, [groups, search, activeCategory]); - - const totalMatches = filtered.reduce((sum, group) => sum + group.templates.length, 0); - - return ( - - - - - {picked || pickedBundle ? ( - - ) : ( - "Browse Collection Templates" - )} - - - {picked - ? "Confirm details, then we'll create and sync the collection for you." - : pickedBundle - ? "Choose libraries, preview the defaults, then apply the bundle." - : "Pick a curated source — TMDB or MDBList — and we'll seed a synced collection."} - - - - {picked ? ( - isUserMode ? ( - setPicked(null)} - onCreated={() => { - handleOpenChange(false); - onCreated?.(); - }} - /> - ) : ( - setPicked(null)} - onCreated={() => { - handleOpenChange(false); - onCreated?.(); - }} - /> - ) - ) : pickedBundle && !isUserMode ? ( - { - handleOpenChange(false); - onCreated?.(); - }} - /> - ) : ( - - )} - - - ); -} - -interface GalleryViewProps { - isLoading: boolean; - error: unknown; - bundles: CollectionTemplateBundle[]; - groups: CollectionTemplateGroup[]; - filtered: CollectionTemplateGroup[]; - totalMatches: number; - search: string; - setSearch: (value: string) => void; - activeCategory: ActiveCategory; - setActiveCategory: (value: ActiveCategory) => void; - onPick: (template: CollectionTemplate) => void; - onPickBundle: (bundle: CollectionTemplateBundle) => void; -} - -function GalleryView({ - isLoading, - error, - bundles, - groups, - filtered, - totalMatches, - search, - setSearch, - activeCategory, - setActiveCategory, - onPick, - onPickBundle, -}: GalleryViewProps) { - if (error) { - return ( -
    - Failed to load templates: {error instanceof Error ? error.message : String(error)} -
    - ); - } - - return ( -
    - {bundles.length > 0 ? ( -
    - {bundles.map((bundle) => ( - - ))} -
    - ) : null} - -
    - - setSearch(event.target.value)} - placeholder="Search templates" - className="pl-9" - /> -
    - -
    - setActiveCategory("all")} - /> - {groups.map((group) => ( - setActiveCategory(group.category)} - /> - ))} -
    - - {isLoading ? ( -
    - {Array.from({ length: 6 }).map((_, idx) => ( - - ))} -
    - ) : totalMatches === 0 ? ( -
    - No templates match your filters. -
    - ) : ( -
    - {filtered.map((group) => ( -
    -

    - {group.label} -

    -
    - {group.templates.map((tmpl) => ( - - ))} -
    -
    - ))} -
    - )} -
    - ); -} - -function TemplateBundleApplyView({ - bundle, - libraries, - initialLibraryId, - groups, - onApplied, -}: { - bundle: CollectionTemplateBundle; - libraries: Library[]; - initialLibraryId: number | null; - groups: CollectionTemplateGroup[]; - onApplied?: () => void; -}) { - const initialIds = useMemo(() => { - if (initialLibraryId) return [initialLibraryId]; - return libraries.map((library) => library.id); - }, [initialLibraryId, libraries]); - const templatesById = useMemo(() => { - const map = new Map(); - groups.forEach((group) => - group.templates.forEach((template) => map.set(template.id, template)), - ); - return map; - }, [groups]); - const [libraryIds, setLibraryIds] = useState(initialIds); - const [deleteExisting, setDeleteExisting] = useState(false); - const [homeFeatured, setHomeFeatured] = useState(null); - const [libraryFeatured, setLibraryFeatured] = useState>({}); - const [result, setResult] = useState(null); - const applyBundle = useApplyCollectionTemplateBundle(); - const queueBundleApply = useQueueCollectionTemplateBundleApply(); - const disabled = libraryIds.length === 0 || applyBundle.isPending || queueBundleApply.isPending; - const queuesInitialSyncs = bundle.id === "all_defaults"; - const selectedLibraries = useMemo( - () => libraries.filter((library) => libraryIds.includes(library.id)), - [libraries, libraryIds], - ); - const effectiveLibraryFeatured = useMemo(() => { - const next: Record = {}; - selectedLibraries.forEach((library) => { - const previous = libraryFeatured[library.id]; - if (previous === "none") { - next[library.id] = previous; - return; - } - if ( - previous !== undefined && - isBundleTemplateEligibleForLibrary(templatesById.get(previous), library) - ) { - next[library.id] = previous; - return; - } - next[library.id] = defaultFeaturedTemplateId(bundle, templatesById, library); - }); - return next; - }, [bundle, libraryFeatured, selectedLibraries, templatesById]); - const effectiveHomeFeatured = useMemo(() => { - if (homeFeatured === "none") return homeFeatured; - if (homeFeatured) { - const parsed = parseHomeFeaturedValue(homeFeatured); - const library = selectedLibraries.find((item) => item.id === parsed?.libraryId); - const template = parsed ? templatesById.get(parsed.templateId) : undefined; - if (library && isBundleTemplateEligibleForLibrary(template, library)) { - return homeFeatured; - } - } - return defaultHomeFeaturedValue(bundle, templatesById, selectedLibraries); - }, [bundle, homeFeatured, selectedLibraries, templatesById]); - - const preview = () => { - const featured = buildFeaturedBundleRequest(effectiveHomeFeatured, effectiveLibraryFeatured); - applyBundle.mutate( - { - bundleId: bundle.id, - body: { - library_ids: libraryIds, - dry_run: true, - delete_existing: deleteExisting, - featured, - }, - }, - { - onSuccess: (nextResult) => { - setResult(nextResult); - }, - }, - ); - }; - - const apply = () => { - const featured = buildFeaturedBundleRequest(effectiveHomeFeatured, effectiveLibraryFeatured); - queueBundleApply.mutate( - { - bundleId: bundle.id, - body: { - library_ids: libraryIds, - delete_existing: deleteExisting, - featured, - }, - }, - { - onSuccess: () => onApplied?.(), - }, - ); - }; - - return ( -
    -
    -
    {bundle.title}
    -

    {bundle.description}

    - {queuesInitialSyncs ? ( -

    - Collections are created first; initial syncs are queued so this large bundle can finish - without waiting on every source. -

    - ) : null} -
    - -
    -
    Libraries
    - -
    - -
    -
    -
    Featured Sections
    -

    - Create one hero section for Home and one for each selected library. -

    -
    - -
    - - -
    - -
    - {selectedLibraries.map((library) => ( -
    - - -
    - ))} -
    -
    - - - -
    - - -
    - - {result ? : null} -
    - ); -} - -function eligibleBundleTemplates( - bundle: CollectionTemplateBundle, - templatesById: Map, - library: Library, -): CollectionTemplate[] { - return bundle.template_ids - .map((id) => templatesById.get(id)) - .filter((template): template is CollectionTemplate => - isBundleTemplateEligibleForLibrary(template, library), - ); -} - -function isBundleTemplateEligibleForLibrary( - template: CollectionTemplate | undefined, - library: Library, -): template is CollectionTemplate { - if (!template) return false; - const eligibility = libraryEligibilityForMediaKind(template.media_kind); - if (!eligibility.kinds || eligibility.kinds.length === 0) return true; - if (!library.type || library.type === "mixed") return true; - return eligibility.kinds.includes(library.type); -} - -function defaultFeaturedTemplateId( - bundle: CollectionTemplateBundle, - templatesById: Map, - library: Library, -): string { - for (const id of [ - "tmdb_trending_movies_week", - "tmdb_trending_tv_week", - "tmdb_popular_movies", - "tmdb_popular_tv", - ]) { - if (!bundle.template_ids.includes(id)) continue; - const template = templatesById.get(id); - if (isBundleTemplateEligibleForLibrary(template, library)) { - return id; - } - } - return eligibleBundleTemplates(bundle, templatesById, library)[0]?.id ?? "none"; -} - -function defaultHomeFeaturedValue( - bundle: CollectionTemplateBundle, - templatesById: Map, - libraries: Library[], -): string { - for (const id of ["tmdb_trending_movies_week", "tmdb_trending_tv_week"]) { - if (!bundle.template_ids.includes(id)) continue; - const library = libraries.find((candidate) => - isBundleTemplateEligibleForLibrary(templatesById.get(id), candidate), - ); - if (library) return `${library.id}:${id}`; - } - for (const library of libraries) { - const templateID = defaultFeaturedTemplateId(bundle, templatesById, library); - if (templateID !== "none") return `${library.id}:${templateID}`; - } - return "none"; -} - -function parseHomeFeaturedValue(value: string): { libraryId: number; templateId: string } | null { - if (value === "none") return null; - const [libraryID, templateID] = value.split(":"); - const parsedLibraryID = Number(libraryID); - if (!Number.isInteger(parsedLibraryID) || parsedLibraryID <= 0 || !templateID) return null; - return { libraryId: parsedLibraryID, templateId: templateID }; -} - -function buildFeaturedBundleRequest( - homeFeatured: string, - libraryFeatured: Record, -): ApplyCollectionTemplateBundleFeaturedRequest | undefined { - const request: ApplyCollectionTemplateBundleFeaturedRequest = {}; - const home = parseHomeFeaturedValue(homeFeatured); - if (home) { - request.home = { - library_id: home.libraryId, - template_id: home.templateId, - }; - } - - const libraries = Object.fromEntries( - Object.entries(libraryFeatured).filter(([, templateID]) => templateID !== "none"), - ); - if (Object.keys(libraries).length > 0) { - request.libraries = libraries; - } - - if (!request.home && !request.libraries) return undefined; - return request; -} - -function BundleApplyResult({ result }: { result: ApplyCollectionTemplateBundleResponse }) { - const actionLabel = result.dry_run ? "Would create" : "Created"; - const deleteActionLabel = result.dry_run ? "Would delete" : "Deleted"; - const created = result.created ?? []; - const skipped = result.skipped ?? []; - const failed = result.failed ?? []; - const deleted = result.deleted ?? []; - const deleteFailed = result.delete_failed ?? []; - const deleteSkipped = result.delete_skipped ?? []; - const syncQueued = result.sync_queued ?? []; - const featured = result.featured ?? []; - const featuredFailed = result.featured_failed ?? []; - return ( -
    -
    - {result.delete_existing ? ( - <> - {deleteActionLabel} {deleted.length}; delete skipped {deleteSkipped.length}; delete - failed {deleteFailed.length};{" "} - - ) : null} - {actionLabel} {created.length}; skipped {skipped.length}; failed {failed.length} - {syncQueued.length > 0 ? `; sync queued ${syncQueued.length}` : ""}; featured{" "} - {featured.length}; featured failed {featuredFailed.length} -
    - {syncQueued.length > 0 ? ( -
    - Initial syncs are running in the background. Collections are available now; items appear - as each sync finishes. -
    - ) : null} - {deleteFailed.length > 0 ? ( -
    - {deleteFailed.slice(0, 5).map((entry) => ( -
    - {entry.library_name} / {entry.collection_title ?? entry.collection_id}:{" "} - {entry.reason ?? "failed"} -
    - ))} -
    - ) : null} - {failed.length > 0 ? ( -
    - {failed.slice(0, 5).map((entry) => ( -
    - {entry.library_name} / {entry.template_title}: {entry.reason ?? "failed"} -
    - ))} -
    - ) : null} - {featuredFailed.length > 0 ? ( -
    - {featuredFailed.slice(0, 5).map((entry) => ( -
    - {entry.surface === "home" ? "Home" : entry.library_name} / {entry.template_title}:{" "} - {entry.reason ?? "failed"} -
    - ))} -
    - ) : null} -
    - ); -} - -function CategoryPill({ - label, - active, - onClick, -}: { - label: string; - active: boolean; - onClick: () => void; -}) { - return ( - - ); -} diff --git a/web/src/components/CollectionTemplateGallery/MDBListBrowser.tsx b/web/src/components/CollectionTemplateGallery/MDBListBrowser.tsx index a266966f55..dcdf9f16aa 100644 --- a/web/src/components/CollectionTemplateGallery/MDBListBrowser.tsx +++ b/web/src/components/CollectionTemplateGallery/MDBListBrowser.tsx @@ -1,110 +1,258 @@ -import { useState } from "react"; +import { useId, useMemo, useState } from "react"; +import { Info, Link2, Search } from "lucide-react"; +import { Link } from "react-router"; +import { RadioGroup as RadioGroupPrimitive } from "radix-ui"; import type { MDBListListSummary } from "@/api/types"; -import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; import { useDebounce } from "@/hooks/useDebounce"; -import { useMDBListSearch, useMDBListTop } from "@/hooks/queries/userCollectionImports"; +import { useMDBListSearch } from "@/hooks/queries/userCollectionImports"; +import type { CollectionTemplate, CollectionTemplateGroup } from "@/lib/collectionTemplates"; +import { + FROM_MDBLIST, + FROM_MDBLIST_HELP, + MDBLIST_LINK_INVALID, + MDBLIST_SEARCH_OFF_PROFILE, + PASTE_MDBLIST_LINK, + POPULAR_PICKS, + POPULAR_PICKS_HELP, +} from "@/lib/collections/copy"; +import { + cleanMDBListLink, + isMDBListLink, + mdblistPick, + mdblistPickId, + templatePick, + type SyncedPick, +} from "@/lib/collections/synced"; +import type { ScopeKind } from "@/lib/collections/scope"; +import { cn } from "@/lib/utils"; -interface Props { - onPick: (list: MDBListListSummary, jsonURL: string) => void; +import { CollectionTemplateCard, PickRow } from "./CollectionTemplateCard"; + +const ALL = "all"; + +function matches(template: CollectionTemplate, term: string) { + return [template.title, template.description, ...(template.tags ?? [])].some((text) => + text.toLowerCase().includes(term), + ); } -export function MDBListBrowser({ onPick }: Props) { - const [query, setQuery] = useState(""); - const [showTop, setShowTop] = useState(false); +function listMeta(list: MDBListListSummary) { + const kind = list.mediatype === "movie" ? "Movies" : list.mediatype === "show" ? "TV" : null; + return [ + `by ${list.user_name}`, + kind, + `${list.items.toLocaleString()} title${list.items === 1 ? "" : "s"}`, + list.likes > 0 ? `♥ ${list.likes.toLocaleString()}` : null, + ] + .filter(Boolean) + .join(" · "); +} - // 300ms keeps typing responsive while bounding hits against the shared - // 1000/day MDBList free-tier quota. - const debouncedQuery = useDebounce(query, 300); - const search = useMDBListSearch(debouncedQuery, debouncedQuery.length > 0); - const top = useMDBListTop(showTop); +function GroupHeading({ title, help }: { title: string; help: string }) { + return ( +

    + {title}{" "} + {help} +

    + ); +} - const configured = (search.data ?? top.data)?.configured ?? null; +/** + * The MDBList tab of the Synced list step. One search box covers the popular + * picks (filtered here) and MDBList's public lists (searched on the server), + * theme chips narrow the picks, and any MDBList link can be pasted instead. + * With MDBList search off, the box is disabled and says why; picks and links + * still work. A failed search keeps the typed text and the last results. + */ +export function MDBListBrowser({ + scopeKind, + picks, + searchEnabled, + selectedId, + onPick, + link, + onLinkChange, +}: { + scopeKind: ScopeKind; + /** Creatable templates that name their list, by category. */ + picks: readonly CollectionTemplateGroup[]; + /** `mdblist_search` from the scope's capabilities. */ + searchEnabled: boolean; + /** The pick the draft follows, if it came from this tab. */ + selectedId?: string; + onPick: (pick: SyncedPick) => void; + /** The pasted link, as typed. */ + link: string; + onLinkChange: (raw: string) => void; +}) { + const id = useId(); + const [query, setQuery] = useState(""); + const [theme, setTheme] = useState(ALL); + // 300ms keeps typing responsive while bounding hits against MDBList's daily quota. + const settled = useDebounce(query.trim(), 300); + const search = useMDBListSearch(settled, searchEnabled); + const [lastFound, setLastFound] = useState<{ query: string; lists: MDBListListSummary[] }>(); + if (search.data && search.data.lists !== lastFound?.lists && settled) { + setLastFound({ query: settled, lists: search.data.lists }); + } + // A failed search keeps the last results; a new term shows "Searching…" until it answers. + const fallback = search.isError ? lastFound?.lists : undefined; + const found = settled ? (search.data?.lists ?? fallback ?? []) : []; - if (configured === false) { - return ( -
    -

    - MDBList list search isn’t available — an admin needs to add an MDBList API key under{" "} - Settings → Subtitles & Metadata. You can still - paste a list URL below. -

    -
    - ); + const term = query.trim().toLowerCase(); + const shown = useMemo( + () => + picks + .filter((group) => theme === ALL || group.category === theme) + .flatMap((group) => group.templates) + .filter((template) => !term || matches(template, term)), + [picks, term, theme], + ); + const templates = useMemo(() => picks.flatMap((group) => group.templates), [picks]); + + function choose(value: string) { + const template = templates.find((entry) => entry.id === value); + if (template) return onPick(templatePick(template, scopeKind)); + const list = found.find((entry) => mdblistPickId(entry) === value); + if (list) onPick(mdblistPick(list)); } - const showingResults = debouncedQuery.length > 0; - const lists = showingResults ? search.data?.lists : top.data?.lists; - const isLoading = showingResults ? search.isLoading : showTop && top.isLoading; - const error = showingResults ? search.error : top.error; + const cleaned = cleanMDBListLink(link); + const linkInvalid = cleaned !== "" && !isMDBListLink(cleaned); return ( -
    - -
    +
    +
    + setQuery(event.target.value)} - placeholder="e.g. horror, oscar winners, netflix" /> -
    - - {error ? ( -

    - {error instanceof Error ? error.message : "Search failed"} + {searchEnabled ? null : ( +

    + + {scopeKind === "server" ? ( + + Searching MDBList needs an API key in{" "} + + Settings › Subtitles & Metadata + + . Popular picks and pasted links still work. + + ) : ( + {MDBLIST_SEARCH_OFF_PROFILE} + )}

    + )} + {picks.length > 1 ? ( +
    + {[{ category: ALL, label: "All" }, ...picks].map((group) => ( + + ))} +
    ) : null} - {isLoading ? ( -

    Searching…

    - ) : lists && lists.length > 0 ? ( -
      - {lists.map((list) => { - const jsonURL = list.url ? `${list.url}/json` : ""; - return ( -
    • - -
    • - ); - })} -
    - ) : showingResults && search.data ? ( -

    - No public lists matched “{debouncedQuery}”. -

    - ) : null} + + {shown.length > 0 ? ( +
    + + {shown.map((template) => ( + + ))} +
    + ) : null} + {settled ? ( +
    + + {found.map((list) => ( + + ))} + {search.isError ? ( +

    + {`Couldn't search MDBList. ${search.error instanceof Error ? search.error.message : ""}`.trim()} +

    + ) : search.isFetching && found.length === 0 ? ( +

    Searching…

    + ) : search.data && found.length === 0 ? ( +

    + No public lists match “{settled}”. +

    + ) : null} +
    + ) : null} + {shown.length === 0 && !settled ? ( +

    + No popular picks here. Search MDBList or paste a link. +

    + ) : null} +
    + +
    + +
    + + onLinkChange(event.target.value)} + /> +
    + {linkInvalid ? ( + + ) : null} +
    ); } diff --git a/web/src/components/CollectionTemplateGallery/TemplatePosterField.tsx b/web/src/components/CollectionTemplateGallery/TemplatePosterField.tsx deleted file mode 100644 index 79b0ed1784..0000000000 --- a/web/src/components/CollectionTemplateGallery/TemplatePosterField.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { Check } from "lucide-react"; - -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { cn } from "@/lib/utils"; -import type { CollectionTemplate } from "@/lib/collectionTemplates"; - -export type TemplatePosterMode = "default" | "custom"; - -interface TemplatePosterFieldProps { - template: CollectionTemplate; - mode: TemplatePosterMode; - onModeChange: (mode: TemplatePosterMode) => void; - customUrl: string; - onCustomUrlChange: (value: string) => void; - inputId: string; -} - -export function TemplatePosterField({ - template, - mode, - onModeChange, - customUrl, - onCustomUrlChange, - inputId, -}: TemplatePosterFieldProps) { - const hasDefaultPoster = Boolean(template.poster_path); - - return ( -
    - -
    - {hasDefaultPoster ? ( -
    - onModeChange("default")} - /> - onModeChange("custom")} - /> -
    - ) : null} - - {mode === "default" && template.poster_path ? ( -
    - -
    -

    Server default

    -

    {template.poster_path}

    -
    -
    - ) : ( -
    - onCustomUrlChange(event.target.value)} - placeholder="https://example.com/poster.jpg" - /> -
    - )} -
    -
    - ); -} - -function PosterChoiceButton({ - label, - active, - onClick, -}: { - label: string; - active: boolean; - onClick: () => void; -}) { - return ( - - ); -} diff --git a/web/src/components/CollectionTemplateGallery/UserCollectionTemplateConfigForm.tsx b/web/src/components/CollectionTemplateGallery/UserCollectionTemplateConfigForm.tsx deleted file mode 100644 index 9ccd125abb..0000000000 --- a/web/src/components/CollectionTemplateGallery/UserCollectionTemplateConfigForm.tsx +++ /dev/null @@ -1,388 +0,0 @@ -import { useState } from "react"; -import type { FormEvent } from "react"; - -import type { - ImportUserMDBListCollectionRequest, - ImportUserTMDBCollectionRequest, - ImportUserTMDBListCollectionRequest, - UserCollectionMediaFilter, - UserCollectionSyncSchedule, - UserCollectionWatchFilter, -} from "@/api/types"; -import { - useImportUserMDBListCollection, - useImportUserTMDBCollection, - useImportUserTMDBListCollection, -} from "@/hooks/queries/userCollectionImports"; -import { useUserLibraries } from "@/hooks/queries/libraries"; -import { - COLLECTION_MEDIA_FILTER_OPTIONS, - COLLECTION_WATCH_FILTER_OPTIONS, - displayFiltersToQueryDefinition, -} from "@/lib/collectionDisplayFilters"; -import { COLLECTION_SOURCE_ORDER, selectValueToSortConfig } from "@/lib/collectionSortConfig"; -import { CollectionDefaultSortField } from "@/components/collections/CollectionDefaultSortField"; -import { TMDBListURLField } from "@/components/collections/TMDBListURLField"; -import { isValidTMDBListURL } from "@/lib/tmdbList"; -import { - COLLECTION_MAX_ITEMS, - libraryEligibilityForMediaKind, - mediaKindLabel, -} from "@/lib/collectionTemplates"; -import type { CollectionTemplate } from "@/lib/collectionTemplates"; -import { - CollectionLibraryPicker, - parseOptionalPositiveInteger, -} from "@/pages/adminCollectionsShared"; - -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; - -import { MDBListBrowser } from "./MDBListBrowser"; -import { TemplatePosterField, type TemplatePosterMode } from "./TemplatePosterField"; - -interface Props { - template: CollectionTemplate; - onCancel: () => void; - onCreated: () => void; -} - -// MANUAL_SCHEDULE is the in-form sentinel for "no automatic refresh". Radix -// Select disallows empty-string SelectItem values, so we map this to "" only -// at submit time when populating the request body. -const MANUAL_SCHEDULE = "none" as const; -type ScheduleChoice = typeof MANUAL_SCHEDULE | Exclude; - -// SCHEDULE_OPTIONS is the user-allowed cadence set. It mirrors the -// usercollections.AllowedSyncSchedules map on the backend, which caps user -// schedules to >= 24h to keep TMDB/Trakt API quota usage bounded. -const SCHEDULE_OPTIONS: Array<{ value: ScheduleChoice; label: string }> = [ - { value: MANUAL_SCHEDULE, label: "Manual only" }, - { value: "daily", label: "Daily" }, - { value: "weekly", label: "Weekly" }, - { value: "monthly", label: "Monthly" }, -]; - -function scheduleChoiceToRequest(choice: ScheduleChoice): UserCollectionSyncSchedule { - return choice === MANUAL_SCHEDULE ? "" : choice; -} - -// templateDefaultSchedule maps the template's built-in cron into the closest -// user-allowed cadence. Templates that prescribe a sub-daily cadence get -// downgraded to "daily" — users cannot opt into shorter intervals here. -function templateDefaultSchedule(cron: string | undefined): ScheduleChoice { - if (!cron) return MANUAL_SCHEDULE; - const fields = cron.trim().split(/\s+/); - if (fields.length !== 5) return "daily"; - const [, , dom, month, dow] = fields; - if (dom === "1" && month === "*") return "monthly"; - if (dom === "*" && month === "*" && dow !== "*") return "weekly"; - return "daily"; -} - -export function UserCollectionTemplateConfigForm({ template, onCancel, onCreated }: Props) { - const tmdbMutation = useImportUserTMDBCollection(); - const mdblistMutation = useImportUserMDBListCollection(); - const tmdbListMutation = useImportUserTMDBListCollection(); - const { data: libraries = [] } = useUserLibraries(); - - const [title, setTitle] = useState(template.title); - const [description, setDescription] = useState(template.description); - const [limit, setLimit] = useState(template.default_limit ? String(template.default_limit) : ""); - const [schedule, setSchedule] = useState( - templateDefaultSchedule(template.default_sync_schedule), - ); - const [isShared, setIsShared] = useState(false); - const [mdblistUrl, setMdblistUrl] = useState(template.mdblist?.url ?? ""); - const [tmdbListUrl, setTmdbListUrl] = useState(template.tmdb_list?.url ?? ""); - const [libraryIds, setLibraryIds] = useState([]); - const [watchFilter, setWatchFilter] = useState("all"); - const [mediaFilter, setMediaFilter] = useState("all"); - const [posterMode, setPosterMode] = useState(() => - template.poster_path ? "default" : "custom", - ); - const [customPosterUrl, setCustomPosterUrl] = useState(""); - const [defaultSort, setDefaultSort] = useState(COLLECTION_SOURCE_ORDER); - - const eligibility = libraryEligibilityForMediaKind(template.media_kind); - const pickerLibraries = libraries.map((lib) => ({ - id: lib.id, - name: lib.name, - type: lib.type, - })); - - const parsedLimit = parseOptionalPositiveInteger(limit); - const limitInvalid = limit.trim().length > 0 && parsedLimit === undefined; - const isPending = - tmdbMutation.isPending || mdblistMutation.isPending || tmdbListMutation.isPending; - const missingMDBListURL = template.source === "mdblist" && mdblistUrl.trim().length === 0; - const invalidTMDBListURL = template.source === "tmdb_list" && !isValidTMDBListURL(tmdbListUrl); - const submitDisabled = isPending || limitInvalid || missingMDBListURL || invalidTMDBListURL; - - function handleSubmit(event: FormEvent) { - event.preventDefault(); - if (submitDisabled) return; - - const sharedFields = { - title: title.trim() || template.title, - description: description.trim() || undefined, - sync_schedule: scheduleChoiceToRequest(schedule), - limit: parsedLimit, - is_shared: isShared, - poster_url: - posterMode === "custom" - ? customPosterUrl.trim() || undefined - : template.poster_path || undefined, - library_ids: libraryIds.length > 0 ? libraryIds : undefined, - display_query_definition: displayFiltersToQueryDefinition(watchFilter, mediaFilter), - sort_config: selectValueToSortConfig(defaultSort), - }; - - if (template.source === "tmdb" && template.tmdb) { - const body: ImportUserTMDBCollectionRequest = { - ...sharedFields, - preset: template.tmdb.preset, - media_type: template.tmdb.media_type, - time_window: template.tmdb.time_window, - }; - tmdbMutation.mutate(body, { onSuccess: onCreated }); - return; - } - - if (template.source === "mdblist") { - const body: ImportUserMDBListCollectionRequest = { - ...sharedFields, - url: mdblistUrl.trim(), - }; - mdblistMutation.mutate(body, { onSuccess: onCreated }); - return; - } - - if (template.source === "tmdb_list") { - const body: ImportUserTMDBListCollectionRequest = { - ...sharedFields, - url: tmdbListUrl.trim(), - }; - tmdbListMutation.mutate(body, { onSuccess: onCreated }); - return; - } - } - - return ( -
    -
    -
    - {template.icon} -
    -
    -
    - {template.title} - - {template.source} - - - {mediaKindLabel(template.media_kind)} - -
    -

    {template.description}

    -
    -
    - -
    - - setTitle(event.target.value)} - required - /> -
    - -
    - - setDescription(event.target.value)} - placeholder="Optional summary" - /> -
    - - {template.source === "mdblist" ? ( -
    - { - setMdblistUrl(jsonURL); - if (!title.trim() || title.trim() === template.title) { - setTitle(list.name); - } - }} - /> -
    - - setMdblistUrl(event.target.value)} - placeholder="https://mdblist.com/lists/user/slug" - required - /> -

    - Pick a list above or paste any public MDBList list URL (with or without{" "} - /json). Items resolve via TMDB/IMDb/TVDB IDs. -

    -
    -
    - ) : null} - - {template.source === "tmdb_list" ? ( - - ) : null} - -
    - - -

    - Leave empty to span every library you can see. -

    -
    - -
    -
    - - -
    -
    - - -
    -
    -

    - Uses the active profile’s watched state. Shared profiles may see different results. -

    - - {template.requires_profile ? ( -

    - This template uses your active profile’s connected Trakt account. -

    - ) : null} - - - -
    -
    - - setLimit(event.target.value)} - placeholder={template.default_limit ? String(template.default_limit) : "No limit"} - /> -
    -
    - - -
    -
    - - - -
    -
    -

    Share with other profiles

    -

    - When on, profiles you choose can browse this collection too. -

    -
    - -
    - -
    - - -
    - - ); -} diff --git a/web/src/components/CollectionTemplateGallery/index.ts b/web/src/components/CollectionTemplateGallery/index.ts deleted file mode 100644 index 275df43b51..0000000000 --- a/web/src/components/CollectionTemplateGallery/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { CollectionTemplateGallery } from "./CollectionTemplateGallery"; diff --git a/web/src/components/ConfirmDialog.test.tsx b/web/src/components/ConfirmDialog.test.tsx new file mode 100644 index 0000000000..73a8c5fac5 --- /dev/null +++ b/web/src/components/ConfirmDialog.test.tsx @@ -0,0 +1,88 @@ +import { cleanup, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ConfirmDialog } from "./ConfirmDialog"; + +afterEach(cleanup); + +describe("ConfirmDialog", () => { + it("lists what the action does and says it can't be undone", async () => { + const onConfirm = vi.fn(); + render( + , + ); + const dialog = screen.getByRole("alertdialog", { name: "Delete Studio Ghibli?" }); + const list = within(dialog).getByRole("list", { name: "Rows that go too" }); + expect( + within(list) + .getAllByRole("listitem") + .map((item) => item.textContent), + ).toEqual(["Ghibli on Home", "Ghibli on Movies"]); + expect(within(dialog).getByText("This can't be undone.")).toBeInTheDocument(); + await userEvent.click(within(dialog).getByRole("button", { name: "Delete it and its 2 rows" })); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it("reads the list and the warning as part of the dialog's description", () => { + render( + , + ); + expect( + screen.getByRole("alertdialog", { name: "Delete Studio Ghibli?" }), + ).toHaveAccessibleDescription( + "It goes away from every library's Collections tab. Ghibli on Home Ghibli on Movies This can't be undone.", + ); + }); + + it("shows neither without them", () => { + render( + , + ); + const dialog = screen.getByRole("alertdialog", { name: "Sign out?" }); + expect(within(dialog).queryByRole("list")).not.toBeInTheDocument(); + expect(within(dialog).queryByText("This can't be undone.")).not.toBeInTheDocument(); + expect(dialog).toHaveAccessibleDescription("You'll need to sign in again."); + }); + + it("says why the action didn't happen, as an alert inside the dialog", () => { + render( + , + ); + const dialog = screen.getByRole("alertdialog", { name: "Delete Studio Ghibli?" }); + expect(within(dialog).getByRole("alert")).toHaveTextContent( + "Rows still use it. Remove them first.", + ); + }); +}); diff --git a/web/src/components/ConfirmDialog.tsx b/web/src/components/ConfirmDialog.tsx index 52fad33890..9259a866df 100644 --- a/web/src/components/ConfirmDialog.tsx +++ b/web/src/components/ConfirmDialog.tsx @@ -1,3 +1,4 @@ +import { useId } from "react"; import { AlertDialog, AlertDialogAction, @@ -20,6 +21,16 @@ interface ConfirmDialogProps { variant?: "default" | "destructive"; onConfirm: () => void; isPending?: boolean; + /** Turns the confirm button off without a spinner, e.g. while `error` explains why. */ + confirmDisabled?: boolean; + /** What the action does, one line each, under the description. */ + bullets?: { label: string; items: string[] }; + /** Says "This can't be undone." beside the buttons. */ + irreversible?: boolean; + /** Where focus goes on close, when the control that opened it is gone (e.g. a menu item). */ + onCloseAutoFocus?: (event: Event) => void; + /** Why the action didn't happen, shown in the dialog; the caller keeps it open. */ + error?: string | null; } export function ConfirmDialog({ @@ -32,31 +43,76 @@ export function ConfirmDialog({ variant = "default", onConfirm, isPending, + confirmDisabled, + bullets, + irreversible = false, + onCloseAutoFocus, + error, }: ConfirmDialogProps) { + const id = useId(); + const items = bullets?.items ?? []; + const itemId = (index: number) => `${id}-item-${index}`; + const descriptionId = `${id}-description`; + const noteId = `${id}-note`; + // The list and the warning sit outside the description, so name them too: + // a screen reader then reads every consequence when the dialog opens. + const describedBy = [ + descriptionId, + ...items.map((_, index) => itemId(index)), + ...(irreversible ? [noteId] : []), + ].join(" "); + const buttons = ( + <> + {cancelLabel} + + {isPending ? ( + <> + + {confirmLabel} + + ) : ( + confirmLabel + )} + + + ); return ( - + {title} - {description} + + {description} + - - {cancelLabel} - - {isPending ? ( - <> - - {confirmLabel} - - ) : ( - confirmLabel - )} - - + {items.length > 0 ? ( +
      + {items.map((item, index) => ( +
    • + {item} +
    • + ))} +
    + ) : null} + {error ? ( +

    + {error} +

    + ) : null} + {irreversible ? ( + +

    + This can't be undone. +

    +
    {buttons}
    +
    + ) : ( + {buttons} + )}
    ); diff --git a/web/src/components/FilterEasyMode/AddFilterPopover.test.tsx b/web/src/components/FilterEasyMode/AddFilterPopover.test.tsx deleted file mode 100644 index d37154b9bb..0000000000 --- a/web/src/components/FilterEasyMode/AddFilterPopover.test.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; -import AddFilterPopover from "./AddFilterPopover"; - -describe("AddFilterPopover", () => { - it("does not submit when field is blank", async () => { - const onAdd = vi.fn(); - render( {}} />); - await userEvent.click(screen.getByRole("button", { name: /add/i })); - expect(onAdd).not.toHaveBeenCalled(); - }); -}); diff --git a/web/src/components/FilterEasyMode/AddFilterPopover.tsx b/web/src/components/FilterEasyMode/AddFilterPopover.tsx deleted file mode 100644 index 971a4f6843..0000000000 --- a/web/src/components/FilterEasyMode/AddFilterPopover.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { useState } from "react"; -import type { FilterChipModel } from "@/lib/filterEasyMode"; - -const FIELDS = [ - { value: "", label: "Field…" }, - { value: "genre", label: "Genre" }, - { value: "year", label: "Year" }, - { value: "rating_imdb", label: "Rating (IMDb)" }, - { value: "director", label: "Director" }, - { value: "studio", label: "Studio" }, - { value: "cast", label: "Cast" }, - { value: "library", label: "Library" }, - { value: "watched", label: "Has been watched" }, - { value: "language", label: "Language" }, - { value: "runtime", label: "Runtime (min)" }, - { value: "keyword", label: "Keyword" }, -]; - -const OPS = [ - { value: "is", label: "is" }, - { value: "is_not", label: "is not" }, - { value: "gte", label: "≥" }, - { value: "lte", label: "≤" }, - { value: "between", label: "between" }, - { value: "contains", label: "contains" }, -]; - -interface Props { - open: boolean; - onAdd: (chip: FilterChipModel) => void; - onCancel: () => void; -} - -export default function AddFilterPopover({ open, onAdd, onCancel }: Props) { - const [field, setField] = useState(""); - const [op, setOp] = useState("contains"); - const [value, setValue] = useState(""); - if (!open) return null; - return ( -
    -
    - - - -
    -
    - - -
    -
    - ); -} diff --git a/web/src/components/FilterEasyMode/FilterChip.tsx b/web/src/components/FilterEasyMode/FilterChip.tsx deleted file mode 100644 index 8edd9c46e5..0000000000 --- a/web/src/components/FilterEasyMode/FilterChip.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { FilterChipModel } from "@/lib/filterEasyMode"; - -interface Props { - chip: FilterChipModel; - onRemove: () => void; -} - -function formatValue(v: FilterChipModel["value"]): string { - if (v === null || v === undefined) return ""; - if (Array.isArray(v) && v.length === 2) { - return `${v[0]} – ${v[1]}`; - } - return String(v); -} - -export default function FilterChip({ chip, onRemove }: Props) { - return ( - - {chip.field} - {formatValue(chip.value)} - - - ); -} diff --git a/web/src/components/FilterEasyMode/FilterEasyMode.test.tsx b/web/src/components/FilterEasyMode/FilterEasyMode.test.tsx deleted file mode 100644 index ccba66180d..0000000000 --- a/web/src/components/FilterEasyMode/FilterEasyMode.test.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, it, expect, vi } from "vitest"; -import FilterEasyMode from "./FilterEasyMode"; - -describe("FilterEasyMode", () => { - it("renders quick-start templates and chip area", () => { - render( {}} />); - expect(screen.getByText("Highly rated")).toBeInTheDocument(); - expect(screen.getByText(/Add filter/)).toBeInTheDocument(); - }); - - it("adds a chip via the popover", async () => { - const onChange = vi.fn(); - render(); - await userEvent.click(screen.getByRole("button", { name: /Add filter/ })); - await userEvent.selectOptions(screen.getByLabelText(/field/i), "genre"); - await userEvent.type(screen.getByLabelText(/value/i), "Drama"); - await userEvent.click(screen.getByRole("button", { name: /^Add$/ })); - expect(onChange).toHaveBeenCalled(); - const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]; - expect(lastCall?.[0].groups[0].rules[0]).toEqual({ - field: "genre", - op: "contains", - value: "Drama", - }); - }); - - it("removes a chip", async () => { - const onChange = vi.fn(); - render( - , - ); - await userEvent.click(screen.getByRole("button", { name: /remove filter/i })); - expect(onChange).toHaveBeenCalled(); - }); -}); diff --git a/web/src/components/FilterEasyMode/FilterEasyMode.tsx b/web/src/components/FilterEasyMode/FilterEasyMode.tsx deleted file mode 100644 index 7228940073..0000000000 --- a/web/src/components/FilterEasyMode/FilterEasyMode.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { useState } from "react"; -import FilterChip from "./FilterChip"; -import AddFilterPopover from "./AddFilterPopover"; -import QuickStartTemplates from "./QuickStartTemplates"; -import { - chipsToFilterConfig, - filterConfigToChips, - type FilterChipModel, -} from "@/lib/filterEasyMode"; -import type { FilterConfig } from "@/api/types"; - -interface Props { - initialConfig: FilterConfig; - onChange: (next: FilterConfig) => void; -} - -export default function FilterEasyMode({ initialConfig, onChange }: Props) { - const conv = filterConfigToChips(initialConfig); - const [chips, setChips] = useState( - conv.kind === "compatible" ? conv.chips : [], - ); - const [matchMode, setMatchMode] = useState<"all" | "any">( - conv.kind === "compatible" ? conv.matchMode : "all", - ); - const [popoverOpen, setPopoverOpen] = useState(false); - - function emit(nextChips: FilterChipModel[], nextMode: "all" | "any") { - onChange(chipsToFilterConfig(nextChips, nextMode)); - } - - function setAndEmit(nextChips: FilterChipModel[], nextMode: "all" | "any" = matchMode) { - setChips(nextChips); - setMatchMode(nextMode); - emit(nextChips, nextMode); - } - - return ( -
    -
    - Quick-start templates -
    - setAndEmit(picked, matchMode)} /> - -
    - Match - - of these conditions -
    - -
    - {chips.map((c, i) => ( - { - const next = chips.filter((_, j) => j !== i); - setAndEmit(next, matchMode); - }} - /> - ))} - -
    - -
    - setPopoverOpen(false)} - onAdd={(chip) => { - const next = [...chips, chip]; - setAndEmit(next, matchMode); - setPopoverOpen(false); - }} - /> -
    -
    - ); -} diff --git a/web/src/components/FilterEasyMode/PreviewBox.tsx b/web/src/components/FilterEasyMode/PreviewBox.tsx deleted file mode 100644 index 4fcaf7dfa9..0000000000 --- a/web/src/components/FilterEasyMode/PreviewBox.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { useEffect, useState } from "react"; -import { previewSection, type PreviewResponse } from "@/lib/recipes"; - -interface Props { - sectionType: string; - config: Record; - itemLimit: number; - libraryID?: number; -} - -export default function PreviewBox({ sectionType, config, itemLimit, libraryID }: Props) { - const [data, setData] = useState(null); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); - - useEffect(() => { - const handle = setTimeout(async () => { - setLoading(true); - setError(null); - try { - const res = await previewSection({ - section_type: sectionType, - config, - item_limit: itemLimit, - library_id: libraryID, - }); - setData(res); - } catch (e) { - setError(String(e)); - } finally { - setLoading(false); - } - }, 300); - return () => clearTimeout(handle); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sectionType, JSON.stringify(config), itemLimit, libraryID]); - - return ( -
    -
    - {loading ? "Loading…" : error ? "Error" : `Preview · ${data?.total_count ?? 0} items match`} -
    - {error &&
    {error}
    } - {!error && data && ( -
    - {data.items - .slice(0, 10) - .map((i) => i.title ?? i.content_id) - .join(" · ") || "no matches"} -
    - )} -
    - ); -} diff --git a/web/src/components/FilterEasyMode/QuickStartTemplates.test.tsx b/web/src/components/FilterEasyMode/QuickStartTemplates.test.tsx deleted file mode 100644 index 6cb721ee15..0000000000 --- a/web/src/components/FilterEasyMode/QuickStartTemplates.test.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; -import QuickStartTemplates from "./QuickStartTemplates"; - -describe("QuickStartTemplates", () => { - it("invokes onPick with the template chips", async () => { - const onPick = vi.fn(); - render(); - await userEvent.click(screen.getByRole("button", { name: /Highly rated/ })); - expect(onPick).toHaveBeenCalled(); - const chips = onPick.mock.calls[0]![0]; - expect(chips.length).toBeGreaterThan(0); - expect(chips[0].field).toBe("rating_imdb"); - }); -}); diff --git a/web/src/components/FilterEasyMode/QuickStartTemplates.tsx b/web/src/components/FilterEasyMode/QuickStartTemplates.tsx deleted file mode 100644 index 35c7753747..0000000000 --- a/web/src/components/FilterEasyMode/QuickStartTemplates.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import type { FilterChipModel } from "@/lib/filterEasyMode"; - -const TEMPLATES: { name: string; desc: string; chips: FilterChipModel[] }[] = [ - { - name: "Highly rated", - desc: "rating ≥ 7.5", - chips: [{ field: "rating_imdb", op: "gte", value: 7.5 }], - }, - { - name: "By decade", - desc: "year range", - chips: [{ field: "year", op: "between", value: [1990, 1999] }], - }, - { - name: "Genre + decade", - desc: "two filters", - chips: [ - { field: "genre", op: "contains", value: "Sci-Fi" }, - { field: "year", op: "between", value: [1980, 1989] }, - ], - }, - { - name: "Cast / crew", - desc: "person filter", - chips: [{ field: "cast", op: "is", value: "" }], - }, - { - name: "Watchlist + unwatched", - desc: "profile-aware", - chips: [{ field: "watched", op: "is", value: false }], - }, - { - name: "Empty", - desc: "start blank", - chips: [], - }, -]; - -interface Props { - onPick: (chips: FilterChipModel[]) => void; -} - -export default function QuickStartTemplates({ onPick }: Props) { - return ( -
    - {TEMPLATES.map((t) => ( - - ))} -
    - ); -} diff --git a/web/src/components/FilterRuleEditor.test.tsx b/web/src/components/FilterRuleEditor.test.tsx index c5110c958d..f58d508172 100644 --- a/web/src/components/FilterRuleEditor.test.tsx +++ b/web/src/components/FilterRuleEditor.test.tsx @@ -1,15 +1,14 @@ +import { fireEvent, render, screen, within } from "@testing-library/react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it, vi } from "vitest"; import { COLLECTION_FIELD_OPTIONS, - COLLECTION_SORT_OPTIONS, - getCollectionSortOptions, getCollectionFieldOption, + getCollectionSortOptions, } from "@/components/collections/collectionBuilderFields"; -import FilterRuleEditor from "./FilterRuleEditor"; -import { getFilterRuleFieldOptions } from "./FilterRuleEditor"; +import FilterRuleEditor, { getFilterRuleFieldOptions } from "./FilterRuleEditor"; vi.mock("@/hooks/queries/ratingsCapability", () => ({ useShownRatingSources: () => new Set(["imdb", "tmdb"]), @@ -53,8 +52,8 @@ describe("FilterRuleEditor", () => { "in_watchlist", ]), ); - expect(COLLECTION_SORT_OPTIONS.map((sort) => sort.value)).toContain("rating_imdb"); - expect(COLLECTION_SORT_OPTIONS.map((sort) => sort.value)).not.toContain("rating"); + expect(getCollectionSortOptions().map((sort) => sort.value)).toContain("rating_imdb"); + expect(getCollectionSortOptions().map((sort) => sort.value)).not.toContain("rating"); expect(getCollectionSortOptions(false).map((sort) => sort.value)).not.toContain("progress"); }); @@ -64,7 +63,7 @@ describe("FilterRuleEditor", () => { supportsRange: true, }); expect(getCollectionFieldOption("release_date")).toMatchObject({ - inputType: "text", + inputType: "date", supportsRange: true, }); expect(getCollectionFieldOption("actor")).toMatchObject({ @@ -77,7 +76,7 @@ describe("FilterRuleEditor", () => { }); expect(getCollectionFieldOption("resolution")).toMatchObject({ inputType: "select", - selectOptions: expect.arrayContaining(["2160p"]), + selectOptions: expect.arrayContaining([{ value: "2160p", label: "2160p" }]), }); }); @@ -87,8 +86,122 @@ describe("FilterRuleEditor", () => { expect(ebookOptions.find((option) => option.value === "watched")?.label).toBe("Read"); expect(ebookOptions.find((option) => option.value === "in_progress")?.label).toBe( - "In Progress", + "In progress", ); expect(movieOptions.find((option) => option.value === "watched")?.label).toBe("Watched"); }); + + it("names each rule's controls by the rule's number", () => { + render( + {}} + />, + ); + + const second = screen.getByRole("group", { name: "Rule 2" }); + expect(within(second).getByRole("combobox", { name: "Field" })).toHaveTextContent( + "IMDb rating", + ); + expect(within(second).getByRole("button", { name: "Remove rule" })).toBeInTheDocument(); + }); + + it("shows rules its controls cannot represent as read-only rules", () => { + render( + {}} + />, + ); + + expect( + screen + .getAllByRole("group", { name: "Rule not editable here" }) + .map((rule) => rule.textContent), + ).toEqual([ + "Not editable here year contains 1999Remove", + 'Not editable here year between "1990-1999"Remove', + 'Not editable here hdr is "true"Remove', + "Not editable here in_watchlist is trueRemove", + ]); + }); + + // The guided editor writes fields the server accepts but these controls do + // not offer, so the read-only label must not call them broken. + it("does not call valid guided-editor rules unsupported", () => { + render( + {}} + />, + ); + + expect(screen.queryByText(/unsupported/i)).toBeNull(); + expect( + screen + .getAllByRole("group", { name: "Rule not editable here" }) + .map((rule) => rule.textContent), + ).toEqual([ + 'Not editable here original_language is "fr"Remove', + 'Not editable here author is "Ursula K. Le Guin"Remove', + ]); + }); + + it("keeps a cleared number value empty instead of writing zero", () => { + const onChange = vi.fn(); + render( + , + ); + + fireEvent.change(screen.getByRole("spinbutton", { name: "Value" }), { + target: { value: "" }, + }); + + expect(onChange).toHaveBeenLastCalledWith({ + match: "all", + groups: [{ match: "all", rules: [{ field: "rating_imdb", op: "gte", value: "" }] }], + }); + }); }); diff --git a/web/src/components/FilterRuleEditor.tsx b/web/src/components/FilterRuleEditor.tsx index 04a38f9e87..137a659f74 100644 --- a/web/src/components/FilterRuleEditor.tsx +++ b/web/src/components/FilterRuleEditor.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef, useState } from "react"; import type { PersonalizedSorts } from "@/lib/querySortOptions"; import { useShownRatingSources } from "@/hooks/queries/ratingsCapability"; import { Button } from "@/components/ui/button"; @@ -5,18 +6,27 @@ import { Input } from "@/components/ui/input"; import { Select, SelectContent, + SelectGroup, SelectItem, + SelectLabel, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Plus, Trash2 } from "lucide-react"; +import { cn } from "@/lib/utils"; import type { FilterConfig, FilterGroup, FilterRule } from "@/api/types"; import { + COLLECTION_FIELD_GROUPS, COLLECTION_FIELD_OPTIONS, getCollectionSortOptions, getCollectionFieldOption, + getDefaultRuleValue, + newFilterRule, + type CollectionFieldOption, } from "@/components/collections/collectionBuilderFields"; +import { FacetValuePicker } from "@/components/ui/facet-value-picker"; import { PersonSearchSelect } from "@/components/ui/person-search-select"; +import type { FacetValueScope } from "@/hooks/queries/facetValues"; import { getDefaultQuerySortOrder, normalizeQuerySortForScope, @@ -40,6 +50,8 @@ interface FilterRuleEditorProps { allowPersonalizedSorts?: PersonalizedSorts; sortRelevanceScope?: QuerySortRelevanceScope; mediaScope?: FilterRuleMediaScope; + /** Where picked values come from; without it, all of the viewer's titles. */ + valueScope?: FacetValueScope; } export function getFilterRuleFieldOptions( @@ -62,70 +74,528 @@ export function getFilterRuleFieldOptions( }); } -export default function FilterRuleEditor({ +/** + * Whether the rule's field, operator and value fit the editor's controls. + * Any other rule is shown read-only and kept exactly as saved unless removed. + * Many such rules are valid, for example ones the guided editor writes for + * fields these controls do not offer, so the label must not call them broken. + */ +function canEditRule( + rule: FilterRule, + fieldDef: CollectionFieldOption | undefined, + allowPersonalizedFilters: boolean, +): boolean { + if (!fieldDef || (fieldDef.personalized && !allowPersonalizedFilters)) return false; + if (!fieldDef.operators.some((op) => op.value === rule.op)) return false; + if (rule.op === "between") return Array.isArray(rule.value) && rule.value.length === 2; + if (fieldDef.inputType === "boolean") return typeof rule.value === "boolean"; + return true; +} + +const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; +/** "30d": the server trims and ignores case, and reads m as months. */ +const IN_LAST = /^\s*(\d+)\s*([hdwmy])\s*$/i; +const IN_LAST_UNITS: ReadonlyArray<[string, string]> = [ + ["d", "days"], + ["w", "weeks"], + ["m", "months"], + ["y", "years"], +]; + +function normalizeRuleValue( + field: string, + op: string, + value: FilterRule["value"], +): FilterRule["value"] { + const fieldDef = getCollectionFieldOption(field); + if (!fieldDef) { + return value; + } + if (op === "between" && fieldDef.supportsRange) { + if (Array.isArray(value) && value.length === 2) { + return value; + } + return ["", ""]; + } + if (Array.isArray(value)) { + // Leaving "between" keeps where the range started, if the new condition + // takes such a value. + value = value[0] ?? getDefaultRuleValue(field, op); + } + if (fieldDef.inputType === "boolean") { + if (typeof value === "boolean") { + return value; + } + return String(value) === "true"; + } + if (fieldDef.inputType === "date") { + // A date and "in the last 30 days" don't convert into each other. + const text = String(value ?? ""); + return (op === "in_last" ? IN_LAST : ISO_DATE).test(text) ? text : ""; + } + return value; +} + +interface FilterRuleRowProps { + rule: FilterRule; + fieldOptions: CollectionFieldOption[]; + allowPersonalizedFilters: boolean; + onChange: (updates: Partial) => void; + onRemove: () => void; + /** Taller controls that wrap onto a second line when narrow, for roomier forms. */ + roomy?: boolean; + /** + * Names the rule's controls as a group ("Rule 2"), so a screen reader can + * tell which rule a Field, Value or Remove rule control belongs to. + */ + label?: string; + /** Where picked values come from; without it, all of the viewer's titles. */ + valueScope?: FacetValueScope; + /** Moves focus to the value control, for a rule just added. */ + focusValue?: boolean; +} + +const RULE_ROW_SIZES = { + compact: { + row: "flex items-center gap-2", + control: "h-8 text-xs", + field: "w-36", + op: "w-24", + value: "flex-1", + }, + roomy: { + row: "flex flex-wrap items-center gap-2", + control: "h-9 text-sm", + field: "w-40", + op: "w-36", + value: "min-w-40 flex-1", + }, +}; + +type RuleRowSize = (typeof RULE_ROW_SIZES)["roomy"]; + +/** + * One rule: field, condition and value, then a remove button. A rule these + * controls can't represent shows read-only and stays as saved until removed. + */ +export function FilterRuleRow({ + rule, + fieldOptions, + allowPersonalizedFilters, + onChange, + onRemove, + roomy = false, + label, + valueScope, + focusValue = false, +}: FilterRuleRowProps) { + const size = RULE_ROW_SIZES[roomy ? "roomy" : "compact"]; + const fieldDef = getCollectionFieldOption(rule.field); + const valueRef = useRef(null); + + useEffect(() => { + if (focusValue) valueRef.current?.querySelector("button, input")?.focus(); + }, [focusValue]); + + if (!fieldDef || !canEditRule(rule, fieldDef, allowPersonalizedFilters)) { + return ( +
    + + Not editable here{" "} + + {rule.field} {rule.op} {JSON.stringify(rule.value)} + + + +
    + ); + } + + // Status and genre "contains" are offered only to a rule that already uses them. + const fields = fieldOptions.filter((f) => !f.hidden || f.value === fieldDef.value); + const operators = fieldDef.operators.filter((op) => !op.hidden || op.value === rule.op); + + return ( +
    + + + + +
    + onChange({ value })} + /> +
    + + +
    + ); +} + +/** The control a rule's value takes, by its field's input type and condition. */ +function RuleValueControl({ + rule, + fieldDef, + size, + valueScope, + onChange, +}: { + rule: FilterRule; + fieldDef: CollectionFieldOption; + size: RuleRowSize; + valueScope: FacetValueScope | undefined; + onChange: (value: FilterRule["value"]) => void; +}) { + const control = cn(size.control, "min-w-0"); + const unit = fieldDef.unit ? ( + {fieldDef.unit} + ) : null; + + if (fieldDef.supportsRange && rule.op === "between") { + const range = Array.isArray(rule.value) && rule.value.length === 2 ? rule.value : ["", ""]; + return ( +
    + {(["From", "To"] as const).map((name, index) => { + const set = (next: string | number) => { + const value: [string | number, string | number] = [range[0] ?? "", range[1] ?? ""]; + value[index] = next; + onChange(value); + }; + return fieldDef.inputType === "date" ? ( + + ) : ( + set(inputValue(fieldDef, e.target.value))} + className={cn(control, "flex-1")} + /> + ); + })} + {unit} +
    + ); + } + + switch (fieldDef.inputType) { + case "boolean": + return ( + + ); + case "select": + return ( + + ); + case "person_search": + return ; + case "facet": + return ( + + ); + case "date": + return rule.op === "in_last" ? ( + + ) : ( + + ); + default: + return ( +
    + onChange(inputValue(fieldDef, e.target.value))} + className={cn(control, "flex-1")} + placeholder="Value" + /> + {unit} +
    + ); + } +} + +/** A typed value: a number for number fields, except that a cleared field stays empty. */ +function inputValue(fieldDef: CollectionFieldOption, text: string): string | number { + return fieldDef.inputType === "number" && text !== "" ? Number(text) : text; +} + +/** A calendar date; a saved value that isn't YYYY-MM-DD stays editable as text. */ +function DateInput({ + label, value, onChange, - allowPersonalizedFilters = false, + className, +}: { + label: string; + value: FilterRule["value"] | undefined; + onChange: (value: string) => void; + className: string; +}) { + const text = String(value ?? ""); + return ( + onChange(e.target.value)} + className={className} + /> + ); +} + +/** + * "in the last [30] [days]", written as "30d". A saved value in hours keeps + * an hours choice; one that doesn't read as a number and unit stays as text. + */ +function InLastInput({ + value, + onChange, + className, + control, +}: { + value: FilterRule["value"]; + onChange: (value: string) => void; + className: string; + control: string; +}) { + const text = String(value ?? ""); + const parsed = IN_LAST.exec(text); + // The unit picked while the number is blank, which writes nothing yet. + const [unitDraft, setUnitDraft] = useState("d"); + + if (text.trim() !== "" && !parsed) { + return ( +
    + onChange(e.target.value)} + className={cn(control, "flex-1")} + /> +
    + ); + } + + const amount = parsed?.[1] ?? ""; + const unit = parsed?.[2]?.toLowerCase() ?? unitDraft; + const units = unit === "h" ? [["h", "hours"] as const, ...IN_LAST_UNITS] : IN_LAST_UNITS; + const write = (nextAmount: string, nextUnit: string) => { + setUnitDraft(nextUnit); + // The server rejects a span of 0, so an amount below 1 writes nothing. + const count = /^\d+$/.test(nextAmount) ? Number(nextAmount) : 0; + onChange(count >= 1 ? `${count}${nextUnit}` : ""); + }; + + return ( +
    + write(e.target.value.trim(), unit)} + className={cn(control, "w-20 flex-none")} + /> + +
    + ); +} + +interface FilterSortControlsProps { + sort?: string; + order?: string; + /** A new field sends both; a new direction sends only the order. */ + onChange: (next: { sort?: string; order: string }) => void; + allowPersonalizedSorts?: PersonalizedSorts; + sortRelevanceScope?: QuerySortRelevanceScope; +} + +/** "Sort by" a field, then ascending or descending. */ +export function FilterSortControls({ + sort, + order, + onChange, allowPersonalizedSorts = false, sortRelevanceScope, - mediaScope = "all", -}: FilterRuleEditorProps) { - const config = value || { match: "all", groups: [] }; +}: FilterSortControlsProps) { const shownRatingSources = useShownRatingSources(); const sortOptions = getCollectionSortOptions( allowPersonalizedSorts, sortRelevanceScope, shownRatingSources, - config.sort, + sort, ); const selectedSort = normalizeQuerySortForScope( - { field: config.sort, order: config.order }, + { field: sort, order }, { includePersonalized: allowPersonalizedSorts, relevanceScope: sortRelevanceScope, shownRatingSources, - keepSortField: config.sort, + keepSortField: sort, }, ); - const fieldOptions = getFilterRuleFieldOptions(allowPersonalizedFilters, mediaScope); - - function getDefaultRuleValue(field: string, op: string): FilterRule["value"] { - const fieldDef = getCollectionFieldOption(field); - if (op === "between" && fieldDef?.supportsRange) { - return ["", ""]; - } - if (fieldDef?.inputType === "boolean") { - return false; - } - if (fieldDef?.inputType === "number") { - return 0; - } - return ""; - } + return ( + <> + + + + ); +} - function normalizeRuleValue( - field: string, - op: string, - value: FilterRule["value"], - ): FilterRule["value"] { - const fieldDef = getCollectionFieldOption(field); - if (!fieldDef) { - return value; - } - if (op === "between" && fieldDef.supportsRange) { - if (Array.isArray(value) && value.length === 2) { - return value; - } - return ["", ""]; - } - if (fieldDef.inputType === "boolean") { - if (typeof value === "boolean") { - return value; - } - return String(value) === "true"; - } - return value; - } +export default function FilterRuleEditor({ + value, + onChange, + allowPersonalizedFilters = false, + allowPersonalizedSorts = false, + sortRelevanceScope, + mediaScope = "all", + valueScope, +}: FilterRuleEditorProps) { + const config = value || { match: "all", groups: [] }; + const fieldOptions = getFilterRuleFieldOptions(allowPersonalizedFilters, mediaScope); function updateConfig(updates: Partial) { onChange({ ...config, ...updates }); @@ -133,13 +603,7 @@ export default function FilterRuleEditor({ function addGroup() { updateConfig({ - groups: [ - ...config.groups, - { - match: "all", - rules: [{ field: "genre", op: "is", value: getDefaultRuleValue("genre", "is") }], - }, - ], + groups: [...config.groups, { match: "all", rules: [newFilterRule()] }], }); } @@ -157,12 +621,7 @@ export default function FilterRuleEditor({ function addRule(groupIdx: number) { const group = config.groups[groupIdx]; if (!group) return; - updateGroup(groupIdx, { - rules: [ - ...group.rules, - { field: "genre", op: "is", value: getDefaultRuleValue("genre", "is") }, - ], - }); + updateGroup(groupIdx, { rules: [...group.rules, newFilterRule()] }); } function removeRule(groupIdx: number, ruleIdx: number) { @@ -232,149 +691,18 @@ export default function FilterRuleEditor({
    - {group.rules.map((rule, ruleIdx) => { - const fieldDef = getCollectionFieldOption(rule.field); - const operators = fieldDef?.operators ?? []; - - return ( -
    - - - - - {fieldDef?.supportsRange && rule.op === "between" ? ( -
    - {[0, 1].map((index) => { - const rangeValue = - Array.isArray(rule.value) && rule.value.length === 2 - ? rule.value - : ["", ""]; - return ( - { - const nextValue: [string | number, string | number] = [ - rangeValue[0] ?? "", - rangeValue[1] ?? "", - ]; - nextValue[index] = - fieldDef.inputType === "number" && e.target.value !== "" - ? Number(e.target.value) - : e.target.value; - updateRule(groupIdx, ruleIdx, { value: nextValue }); - }} - className="h-8 flex-1 text-xs" - placeholder={index === 0 ? "From" : "To"} - /> - ); - })} -
    - ) : fieldDef?.inputType === "boolean" ? ( - - ) : fieldDef?.inputType === "select" ? ( - - ) : fieldDef?.inputType === "person_search" ? ( - updateRule(groupIdx, ruleIdx, { value: v })} - /> - ) : ( - - updateRule(groupIdx, ruleIdx, { - value: - fieldDef?.inputType === "number" - ? Number(e.target.value) - : e.target.value, - }) - } - className="h-8 flex-1 text-xs" - placeholder={rule.field === "added_at" ? "e.g. 30d, 2w" : "Value..."} - /> - )} - - -
    - ); - })} + {group.rules.map((rule, ruleIdx) => ( + updateRule(groupIdx, ruleIdx, updates)} + onRemove={() => removeRule(groupIdx, ruleIdx)} + label={`Rule ${ruleIdx + 1}`} + valueScope={valueScope} + /> + ))}
    ); diff --git a/web/src/components/FilterRuleRow.test.tsx b/web/src/components/FilterRuleRow.test.tsx new file mode 100644 index 0000000000..5741ec1917 --- /dev/null +++ b/web/src/components/FilterRuleRow.test.tsx @@ -0,0 +1,294 @@ +import { useState } from "react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import type { FilterRule } from "@/api/types"; + +import { FilterRuleRow, getFilterRuleFieldOptions } from "./FilterRuleEditor"; + +beforeAll(() => { + Object.defineProperties(Element.prototype, { + hasPointerCapture: { configurable: true, value: () => false }, + setPointerCapture: { configurable: true, value: () => {} }, + releasePointerCapture: { configurable: true, value: () => {} }, + scrollIntoView: { configurable: true, value: () => {} }, + }); +}); + +let latest: FilterRule | undefined; + +function Row({ initial, personalized = true }: { initial: FilterRule; personalized?: boolean }) { + const [rule, setRule] = useState(initial); + return ( + { + const next = { ...rule, ...updates }; + latest = next; + setRule(next); + }} + onRemove={() => {}} + roomy + /> + ); +} + +function renderRow(initial: FilterRule, personalized = true) { + latest = undefined; + return render(); +} + +function open(combobox: HTMLElement) { + fireEvent.pointerDown(combobox, { button: 0, ctrlKey: false, pointerType: "mouse" }); + return screen.getByRole("listbox"); +} + +function choose(combobox: HTMLElement, option: string) { + open(combobox); + fireEvent.click(screen.getByRole("option", { name: option })); +} + +function optionNames(combobox: HTMLElement): string[] { + const names = within(open(combobox)) + .getAllByRole("option") + .map((option) => option.textContent ?? ""); + fireEvent.keyDown(screen.getByRole("listbox"), { key: "Escape" }); + return names; +} + +const field = () => screen.getByRole("combobox", { name: "Field" }); +const condition = () => screen.getByRole("combobox", { name: "Condition" }); + +describe("FilterRuleRow", () => { + it("groups the fields as Title, People, File, You and Library", () => { + renderRow({ field: "genre", op: "is", value: "" }); + const listbox = open(field()); + const groups = within(listbox) + .getAllByRole("group") + .map((group) => [ + group.firstElementChild?.textContent, + within(group) + .getAllByRole("option") + .map((option) => option.textContent), + ]); + expect(groups).toEqual([ + [ + "Title", + [ + "Genre", + "Studio", + "Network", + "Country", + "Content rating", + "Type", + "Year", + "Release date", + "IMDb rating", + ], + ], + ["People", ["Actor", "Director", "Writer", "Producer"]], + ["File", ["Resolution", "HDR", "Dolby Vision", "Bitrate"]], + ["You", ["Watched", "Favorited", "In watchlist", "In progress"]], + ["Library", ["Added"]], + ]); + }); + + it("leaves out the You fields where personalized rules aren't allowed", () => { + renderRow({ field: "genre", op: "is", value: "" }, false); + expect(within(open(field())).queryByRole("group", { name: "You" })).toBeNull(); + expect(screen.queryByRole("option", { name: "Watched" })).toBeNull(); + }); + + it("offers Status and genre “contains” only to a rule that already uses them", () => { + const { unmount } = renderRow({ field: "genre", op: "is", value: "Drama" }); + expect(optionNames(field())).not.toContain("Status"); + expect(optionNames(condition())).toEqual(["is", "is not"]); + unmount(); + + renderRow({ field: "genre", op: "contains", value: "Drama" }); + expect(condition()).toHaveTextContent("contains"); + expect(optionNames(condition())).toEqual(["is", "is not", "contains"]); + expect(screen.getByRole("combobox", { name: "Value" })).toHaveTextContent("Drama"); + }); + + it("keeps a saved Status rule editable", () => { + renderRow({ field: "status", op: "is", value: "unmatched" }); + expect(field()).toHaveTextContent("Status"); + expect(screen.getByRole("combobox", { name: "Value" })).toHaveTextContent("unmatched"); + expect(optionNames(field())).toContain("Status"); + expect(latest).toBeUndefined(); + }); + + it("reads conditions as words", () => { + const { unmount } = renderRow({ field: "rating_imdb", op: "gte", value: 7 }); + expect(condition()).toHaveTextContent("is at least"); + expect(optionNames(condition())).toEqual([ + "is at least", + "is at most", + "is above", + "is below", + "is between", + ]); + unmount(); + + renderRow({ field: "year", op: "is", value: 1999 }); + expect(optionNames(condition())).toEqual([ + "is", + "is at least", + "is at most", + "is above", + "is below", + "is between", + ]); + }); + + it("answers yes or no for a switch, and stores a boolean", () => { + renderRow({ field: "hdr", op: "is", value: false }); + const value = screen.getByRole("combobox", { name: "Value" }); + expect(value).toHaveTextContent("No"); + choose(value, "Yes"); + expect(latest).toEqual({ field: "hdr", op: "is", value: true }); + }); + + it("names a type as Movies or Shows and stores the type", () => { + renderRow({ field: "type", op: "is", value: "series" }); + const value = screen.getByRole("combobox", { name: "Value" }); + expect(value).toHaveTextContent("Shows"); + choose(value, "Movies"); + expect(latest?.value).toBe("movie"); + }); + + it("shows bitrate in the kilobits per second the server compares", () => { + const { unmount } = renderRow({ field: "bitrate", op: "gte", value: 8000 }); + expect(screen.getByRole("spinbutton", { name: "Value" })).toHaveValue(8000); + expect(screen.getByText("kbps")).toBeInTheDocument(); + unmount(); + + renderRow({ field: "bitrate", op: "between", value: [4000, 20000] }); + expect(screen.getByRole("spinbutton", { name: "From" })).toHaveValue(4000); + expect(screen.getByText("kbps")).toBeInTheDocument(); + }); + + it("picks a genre, studio or rating from a picker that names the field", () => { + renderRow({ field: "studio", op: "is", value: "" }); + expect(screen.getByRole("combobox", { name: "Value" })).toHaveTextContent("Pick a studio"); + choose(field(), "Content rating"); + expect(latest).toEqual({ field: "content_rating", op: "is", value: "" }); + expect(screen.getByRole("combobox", { name: "Value" })).toHaveTextContent("Pick a rating"); + }); + + describe("dates", () => { + it("takes a calendar date after or before", () => { + renderRow({ field: "added_at", op: "gt", value: "2024-01-31" }); + expect(condition()).toHaveTextContent("after"); + const input = screen.getByLabelText("Value"); + expect(input).toHaveAttribute("type", "date"); + expect(input).toHaveValue("2024-01-31"); + fireEvent.change(input, { target: { value: "2025-06-01" } }); + expect(latest?.value).toBe("2025-06-01"); + }); + + it("takes two dates between", () => { + renderRow({ field: "release_date", op: "between", value: ["1990-01-01", ""] }); + const to = screen.getByLabelText("To"); + expect(screen.getByLabelText("From")).toHaveValue("1990-01-01"); + expect(to).toHaveAttribute("type", "date"); + fireEvent.change(to, { target: { value: "1999-12-31" } }); + expect(latest?.value).toEqual(["1990-01-01", "1999-12-31"]); + }); + + it("keeps a saved date that isn't YYYY-MM-DD as text", () => { + renderRow({ field: "added_at", op: "lt", value: "2024-01-31T00:00:00Z" }); + const input = screen.getByLabelText("Value"); + expect(input).toHaveAttribute("type", "text"); + expect(input).toHaveValue("2024-01-31T00:00:00Z"); + }); + + it("writes “in the last” as a number and a unit", () => { + renderRow({ field: "added_at", op: "in_last", value: "30d" }); + const amount = screen.getByRole("spinbutton", { name: "Amount" }); + const unit = screen.getByRole("combobox", { name: "Unit" }); + expect(amount).toHaveValue(30); + expect(unit).toHaveTextContent("days"); + expect(optionNames(unit)).toEqual(["days", "weeks", "months", "years"]); + + fireEvent.change(amount, { target: { value: "6" } }); + expect(latest?.value).toBe("6d"); + choose(unit, "months"); + expect(latest?.value).toBe("6m"); + }); + + it("remembers the unit while the number is blank", () => { + renderRow({ field: "release_date", op: "in_last", value: "" }); + const amount = screen.getByRole("spinbutton", { name: "Amount" }); + expect(amount).toHaveValue(null); + choose(screen.getByRole("combobox", { name: "Unit" }), "years"); + expect(latest?.value).toBe(""); + fireEvent.change(amount, { target: { value: "2" } }); + expect(latest?.value).toBe("2y"); + }); + + it("writes nothing for an amount below 1", () => { + renderRow({ field: "added_at", op: "in_last", value: "30d" }); + fireEvent.change(screen.getByRole("spinbutton", { name: "Amount" }), { + target: { value: "0" }, + }); + expect(latest?.value).toBe(""); + }); + + it("keeps hours for a saved value in hours", () => { + renderRow({ field: "added_at", op: "in_last", value: "36h" }); + expect(screen.getByRole("spinbutton", { name: "Amount" })).toHaveValue(36); + const unit = screen.getByRole("combobox", { name: "Unit" }); + expect(unit).toHaveTextContent("hours"); + expect(optionNames(unit)).toEqual(["hours", "days", "weeks", "months", "years"]); + expect(latest).toBeUndefined(); + }); + + it("keeps a saved value it can't read as text, so nothing is lost", () => { + renderRow({ field: "added_at", op: "in_last", value: "last month" }); + const input = screen.getByRole("textbox", { name: "Value" }); + expect(input).toHaveValue("last month"); + expect(screen.queryByRole("spinbutton", { name: "Amount" })).toBeNull(); + expect(latest).toBeUndefined(); + }); + + it("starts over when the condition changes between a date and a span", () => { + renderRow({ field: "added_at", op: "gt", value: "2024-01-31" }); + choose(condition(), "in the last"); + expect(latest).toEqual({ field: "added_at", op: "in_last", value: "" }); + expect(screen.getByRole("spinbutton", { name: "Amount" })).toHaveValue(null); + }); + + it("starts over when the condition changes from a date range to a span", () => { + renderRow({ field: "release_date", op: "between", value: ["1990-01-01", "1999-12-31"] }); + choose(condition(), "in the last"); + expect(latest).toEqual({ field: "release_date", op: "in_last", value: "" }); + }); + + it("keeps a date range's start when the condition changes to after", () => { + renderRow({ field: "release_date", op: "between", value: ["1990-01-01", "1999-12-31"] }); + choose(condition(), "after"); + expect(latest).toEqual({ field: "release_date", op: "gt", value: "1990-01-01" }); + }); + }); + + it("moves focus to the value control when asked", async () => { + render( + , + ); + expect(screen.getByRole("combobox", { name: "Value" })).toHaveFocus(); + await userEvent.tab(); + expect(screen.getByRole("button", { name: "Remove rule" })).toHaveFocus(); + }); +}); diff --git a/web/src/components/Layout.test.tsx b/web/src/components/Layout.test.tsx index 03a34f03ce..71c0637b71 100644 --- a/web/src/components/Layout.test.tsx +++ b/web/src/components/Layout.test.tsx @@ -1,8 +1,8 @@ -import type { ReactNode } from "react"; +import { catalogKeys } from "@/hooks/queries/keys"; import { act, fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; import { MemoryRouter } from "react-router"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { catalogKeys } from "@/hooks/queries/keys"; import { SIDEBAR_DETAILS_REVEAL_DEADLINE_MS } from "./sidebarItemNavigation"; const mocks = vi.hoisted(() => ({ @@ -272,7 +272,7 @@ describe("Layout request routes", () => { // The padded shell wraps the page in one gutter div; unpadded pages render // straight into
    . const isShellPadded = () => - screen.getByRole("main").firstElementChild?.classList.contains("lg:px-10") ?? false; + screen.getByRole("main").firstElementChild?.classList.contains("px-(--page-gutter)") ?? false; it.each([ ["/requests", false, false], diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 0182aa1ec2..39bdaa0f4d 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -467,7 +467,9 @@ export default function Layout({ children }: LayoutProps) { {needsNoPadding ? ( children ) : ( -
    + // `--page-gutter` is published so fixed page chrome (the collection + // editor's save bar) can line up with this content column. +
    {children}
    )} diff --git a/web/src/components/LibraryMultiSelect.tsx b/web/src/components/LibraryMultiSelect.tsx index 24e40cc5f1..29023889fa 100644 --- a/web/src/components/LibraryMultiSelect.tsx +++ b/web/src/components/LibraryMultiSelect.tsx @@ -36,16 +36,17 @@ function formatLibraryFilterSummary( .map((libraryId) => libraries.find((library) => library.id === libraryId)?.name) .filter((name): name is string => Boolean(name)); + if (libraryIds.length === 1) { + return names[0] ?? "1 library"; + } if (names.length === 0) { return `${libraryIds.length} libraries`; } - if (names.length === 1) { - return names[0] ?? "1 library"; - } - if (names.length === 2) { + // Unnamed IDs (deleted or not yet loaded) still count toward "+N more". + if (libraryIds.length === 2 && names.length === 2) { return `${names[0] ?? "Library"}, ${names[1] ?? "Library"}`; } - return `${names[0] ?? "Library"} +${names.length - 1} more`; + return `${names[0] ?? "Library"} +${libraryIds.length - 1} more`; } function toggleLibrarySelection( @@ -65,18 +66,26 @@ export default function LibraryMultiSelect({ onChange, eligibleKinds, emptyLabel = "All Libraries", + allOptionLabel = emptyLabel, hideAllOption = false, ineligibleReason, triggerClassName, + disabled, + triggerLabel, }: { libraries: LibraryOption[]; value: number[]; onChange: (libraryIds: number[]) => void; eligibleKinds?: string[]; emptyLabel?: string; + /** The menu's reset item, when it should read differently from the empty summary. */ + allOptionLabel?: string; hideAllOption?: boolean; ineligibleReason?: string; triggerClassName?: string; + disabled?: boolean; + /** Read before the summary by screen readers, when no visible label names the button. */ + triggerLabel?: string; }) { const hasIneligible = Array.isArray(eligibleKinds) && @@ -90,8 +99,10 @@ export default function LibraryMultiSelect({ type="button" variant="outline" className={triggerClassName ?? "w-full justify-between"} + disabled={disabled} > + {triggerLabel ? {`${triggerLabel}: `} : null} {formatLibraryFilterSummary(value, libraries, emptyLabel)} @@ -107,7 +118,7 @@ export default function LibraryMultiSelect({ onChange([]); }} > - {emptyLabel} + {allOptionLabel} diff --git a/web/src/components/ProfileSectionRow.test.tsx b/web/src/components/ProfileSectionRow.test.tsx deleted file mode 100644 index 5bf9346bf9..0000000000 --- a/web/src/components/ProfileSectionRow.test.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, it, expect, vi } from "vitest"; -import ProfileSectionRow from "./ProfileSectionRow"; - -describe("ProfileSectionRow", () => { - it("shows server-default badge for admin sections and a Hide button", async () => { - const onHide = vi.fn(); - render( -