feat(collections): playlist auto-creation, position ordering, hide_finished, Up Next, dashboard eligibility
Phase 2b + 3 of the metadata overhaul (plan §2.4, §3.1-3.7). Auto-playlist collections (§2.4): the scanner turns obviously-playlist sidecar series (explicit '(Reading Order)'/chronological/publication/ in-order/timeline markers only — precision over recall) into curated static collections: upsert by view_settings.imported_from_series then name (rename-safe), position set ONLY on insert (clean integers; 1.5/1a stay unpositioned rather than guessing), membership add-if-missing, never removed or re-ordered by rescans, provenance in description + view_settings, show_on_dashboard true (dashboard-eligible by design), 'scanner.auto_playlist_collections' setting (default on, Settings Registry + schema seed). Ordering (§3.1-3.2): collection listings order position ASC NULLS LAST then added_at ASC (was added_at DESC — walked playlists backwards); PUT /api/collections/:id/items/order applies full-order rewrites (owner-only, service-gated). hide_finished + Up Next (§3.3-3.4): GetCollectionItemsForDashboard gains viewer_user_id + hide_finished (LEFT JOIN reading_progress, COALESCE percentage < 0.999) and viewer_percentage rides the row for card graying; GetCollectionUpNext serves the collection's reading-queue head with the family-lens visible-library array enforced. Dashboard (§3.5): user collections are dashboard-ELIGIBLE BY DEFAULT — the show_on_dashboard gate (never writable for user rows since the column shipped) is gone from GetUserCollectionsForDashboard; hiding stays dashboard-prefs (hidden_collections) territory; hide_finished filtered-everything now emits a Complete section instead of silently vanishing; sections JSON carries is_playlist/complete. Series page ordering hardening (§3.6): GetSeriesBooks tiebreaks by title ASC — NULL/tied numbers previously returned heap (scan) order, which is what the user saw. Frontend custom-section-builder note: the builder has sent show_on_dashboard: true since its creation; the API never bound it. User-row visibility is now intentionally prefs-governed, so the field stays unbound by design (documented in code). Pins widened (docs/contract_pins.md): collection detail + up_next; dashboard sections + is_playlist/complete; new reorder endpoint recorded. Verified: TestPlaylistCollectionsEndToEnd (new, full e2e: sidecar series split, collection auto-creation with position 7, rescan idempotency, owner-only reorder, finished-filter + completion marker + Up Next drain) plus the full affected integration sweep green.
This commit is contained in:
1 parent
7e4c97f639
commit
1dbb0ce406
15 files changed
+992
-18
No files matched your search
@@ -14,6 +14,9 @@ type Querier interface {
|
||||
// COLLECTION ITEMS QUERIES
|
||||
// Add book to collection
|
||||
AddBookToCollection(ctx context.Context, arg AddBookToCollectionParams) (CollectionItems, error)
|
||||
// A2c: playlist upsert — position set ONLY on insert, so rescans and
|
||||
// re-adds never clobber the user's manual drag ordering.
|
||||
AddBookToCollectionOrdered(ctx context.Context, arg AddBookToCollectionOrderedParams) (CollectionItems, error)
|
||||
// ============================================
|
||||
// KOBO SHELF MANAGEMENT QUERIES
|
||||
// ============================================
|
||||
@@ -42,6 +45,10 @@ type Querier interface {
|
||||
ClearMediaItemMetadataOverrides(ctx context.Context, id pgtype.UUID) error
|
||||
CountAdmins(ctx context.Context) (int64, error)
|
||||
CountArchivedMediaItems(ctx context.Context) (int64, error)
|
||||
// A2c §3.5: distinguish "playlist complete" from "genuinely empty" — the
|
||||
// dashboard renders a completion marker when hide_finished filtered
|
||||
// everything out of a non-empty collection.
|
||||
CountCollectionItems(ctx context.Context, collectionID pgtype.UUID) (int64, error)
|
||||
// Live item count for a STATIC collection (rule-free cards on the
|
||||
// collections page); same live filter as GetCollectionItems, plus the
|
||||
// future-hybrid excluded exclusion and an optional library scope.
|
||||
@@ -174,12 +181,30 @@ type Querier interface {
|
||||
GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error)
|
||||
// Get collection
|
||||
GetCollection(ctx context.Context, id pgtype.UUID) (Collections, error)
|
||||
// A2c §2.4: playlist-collection discovery — provenance key first so a user
|
||||
// rename can't spawn duplicates on the next scan.
|
||||
GetCollectionByImportedSeries(ctx context.Context, arg GetCollectionByImportedSeriesParams) (Collections, error)
|
||||
GetCollectionByUserAndName(ctx context.Context, arg GetCollectionByUserAndNameParams) (Collections, error)
|
||||
// Get collection items
|
||||
// A2c: position-first ordering (playlist reading order); un-positioned
|
||||
// legacy items keep added_at ASC after the positioned tail — NOT DESC,
|
||||
// which walked a playlist backwards.
|
||||
GetCollectionItems(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionItemsRow, error)
|
||||
// The excluded filter lives here (was: callers filtered ci.excluded in
|
||||
// Go over a 69-field row + a 45-field converter to MediaItems; embed
|
||||
// returns the real row so neither exists).
|
||||
// A2c: position-first ordering; optional hide_finished — when the flag is
|
||||
// set AND a viewer is supplied, items the viewer finished (percentage
|
||||
// >= 0.999) drop out, turning the section into the reading queue head.
|
||||
// viewer_user_id NULL = no progress filter (never-finished semantics for
|
||||
// anonymous/legacy callers is NOT implied: hide_finished without a viewer
|
||||
// is a caller bug and shows everything).
|
||||
GetCollectionItemsForDashboard(ctx context.Context, arg GetCollectionItemsForDashboardParams) ([]GetCollectionItemsForDashboardRow, error)
|
||||
// A2c §3.4: the collection's reading queue — first N un-finished items in
|
||||
// position order. Strict playlist semantics: finished items drop out as
|
||||
// they complete, so the head IS "up next". Library visibility is enforced
|
||||
// by the family-lens array param (never NULL for user-facing callers).
|
||||
GetCollectionUpNext(ctx context.Context, arg GetCollectionUpNextParams) ([]GetCollectionUpNextRow, error)
|
||||
// Get collection with book count
|
||||
GetCollectionWithBookCount(ctx context.Context, id pgtype.UUID) (GetCollectionWithBookCountRow, error)
|
||||
// Get collections by user
|
||||
@@ -393,6 +418,10 @@ type Querier interface {
|
||||
GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error)
|
||||
GetUserByEmailOrUsername(ctx context.Context, email string) (GetUserByEmailOrUsernameRow, error)
|
||||
GetUserByUsername(ctx context.Context, username string) (GetUserByUsernameRow, error)
|
||||
// A2c (2026-10-19): user collections are dashboard-ELIGIBLE BY DEFAULT —
|
||||
// show_on_dashboard gated them forever-false (no writer ever set it on
|
||||
// user rows), and hiding is the dashboard-prefs job (hidden_collections).
|
||||
// The flag remains system-section provisioning state only.
|
||||
GetUserCollectionsForDashboard(ctx context.Context, userID pgtype.UUID) ([]Collections, error)
|
||||
// Per-device reading breakdown for a range. LEFT JOIN so pushes that
|
||||
// carry no device (web reader until it identifies) still appear as one
|
||||
@@ -510,6 +539,9 @@ type Querier interface {
|
||||
// Remove book from collection
|
||||
RemoveBookFromCollection(ctx context.Context, arg RemoveBookFromCollectionParams) error
|
||||
RemoveBookFromKoboShelf(ctx context.Context, arg RemoveBookFromKoboShelfParams) error
|
||||
// A2c: full-order rewrite for the drag-to-reorder endpoint. The payload is
|
||||
// the complete ordered id list; positions become 1..n in payload order.
|
||||
ReorderCollectionItems(ctx context.Context, arg ReorderCollectionItemsParams) error
|
||||
// Re-parent all child rows of p_source onto p_target (defined in schema.sql)
|
||||
ReparentMediaItemChildren(ctx context.Context, arg ReparentMediaItemChildrenParams) error
|
||||
ResetSystemCollectionMetadata(ctx context.Context, arg ResetSystemCollectionMetadataParams) error
|
||||
@@ -556,6 +588,9 @@ type Querier interface {
|
||||
// Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write
|
||||
// of the JSONB without touching metadata stale clients did not send).
|
||||
UpdateCollectionRulesJSON(ctx context.Context, arg UpdateCollectionRulesJSONParams) (Collections, error)
|
||||
// A2c §2.4: provenance + playlist flags on an existing collection without
|
||||
// touching the rest of the row.
|
||||
UpdateCollectionViewSettings(ctx context.Context, arg UpdateCollectionViewSettingsParams) error
|
||||
UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error)
|
||||
UpdateDevice(ctx context.Context, arg UpdateDeviceParams) (Devices, error)
|
||||
UpdateDeviceAuthToken(ctx context.Context, arg UpdateDeviceAuthTokenParams) (Devices, error)
|
||||
|
||||
@@ -42,6 +42,42 @@ func (q *Queries) AddBookToCollection(ctx context.Context, arg AddBookToCollecti
|
||||
return i, err
|
||||
}
|
||||
|
||||
const AddBookToCollectionOrdered = `-- name: AddBookToCollectionOrdered :one
|
||||
INSERT INTO collection_items (collection_id, media_item_id, added_by_user_id, position)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (collection_id, media_item_id) DO NOTHING
|
||||
RETURNING id, collection_id, media_item_id, added_at, added_by_user_id, excluded, position
|
||||
`
|
||||
|
||||
type AddBookToCollectionOrderedParams struct {
|
||||
CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"`
|
||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||
AddedByUserID pgtype.UUID `db:"added_by_user_id" json:"added_by_user_id"`
|
||||
Position pgtype.Int4 `db:"position" json:"position"`
|
||||
}
|
||||
|
||||
// A2c: playlist upsert — position set ONLY on insert, so rescans and
|
||||
// re-adds never clobber the user's manual drag ordering.
|
||||
func (q *Queries) AddBookToCollectionOrdered(ctx context.Context, arg AddBookToCollectionOrderedParams) (CollectionItems, error) {
|
||||
row := q.db.QueryRow(ctx, AddBookToCollectionOrdered,
|
||||
arg.CollectionID,
|
||||
arg.MediaItemID,
|
||||
arg.AddedByUserID,
|
||||
arg.Position,
|
||||
)
|
||||
var i CollectionItems
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.CollectionID,
|
||||
&i.MediaItemID,
|
||||
&i.AddedAt,
|
||||
&i.AddedByUserID,
|
||||
&i.Excluded,
|
||||
&i.Position,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const AddBookToKoboShelf = `-- name: AddBookToKoboShelf :one
|
||||
|
||||
INSERT INTO kobo_shelves (device_id, media_item_id, shelf_name, shelf_position)
|
||||
@@ -294,6 +330,20 @@ func (q *Queries) CountArchivedMediaItems(ctx context.Context) (int64, error) {
|
||||
return count, err
|
||||
}
|
||||
|
||||
const CountCollectionItems = `-- name: CountCollectionItems :one
|
||||
SELECT COUNT(*) FROM collection_items WHERE collection_id = $1
|
||||
`
|
||||
|
||||
// A2c §3.5: distinguish "playlist complete" from "genuinely empty" — the
|
||||
// dashboard renders a completion marker when hide_finished filtered
|
||||
// everything out of a non-empty collection.
|
||||
func (q *Queries) CountCollectionItems(ctx context.Context, collectionID pgtype.UUID) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, CountCollectionItems, collectionID)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const CountCollectionLiveItems = `-- name: CountCollectionLiveItems :one
|
||||
SELECT COUNT(*)::int FROM collection_items ci
|
||||
JOIN media_items mi ON ci.media_item_id = mi.id
|
||||
@@ -399,8 +449,8 @@ func (q *Queries) CreateAutoResolvedSyncConflict(ctx context.Context, arg Create
|
||||
|
||||
const CreateCollection = `-- name: CreateCollection :one
|
||||
|
||||
INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at
|
||||
`
|
||||
|
||||
@@ -412,6 +462,7 @@ type CreateCollectionParams struct {
|
||||
Icon pgtype.Text `db:"icon" json:"icon"`
|
||||
AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"`
|
||||
ViewSettings []byte `db:"view_settings" json:"view_settings"`
|
||||
ShowOnDashboard pgtype.Bool `db:"show_on_dashboard" json:"show_on_dashboard"`
|
||||
}
|
||||
|
||||
// COLLECTIONS QUERIES
|
||||
@@ -425,6 +476,7 @@ func (q *Queries) CreateCollection(ctx context.Context, arg CreateCollectionPara
|
||||
arg.Icon,
|
||||
arg.AutoAssignRules,
|
||||
arg.ViewSettings,
|
||||
arg.ShowOnDashboard,
|
||||
)
|
||||
var i Collections
|
||||
err := row.Scan(
|
||||
@@ -2824,6 +2876,77 @@ func (q *Queries) GetCollection(ctx context.Context, id pgtype.UUID) (Collection
|
||||
return i, err
|
||||
}
|
||||
|
||||
const GetCollectionByImportedSeries = `-- name: GetCollectionByImportedSeries :one
|
||||
SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections
|
||||
WHERE user_id = $1
|
||||
AND view_settings->>'imported_from_series' = $2::text
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
type GetCollectionByImportedSeriesParams struct {
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
ImportedFromSeries string `db:"imported_from_series" json:"imported_from_series"`
|
||||
}
|
||||
|
||||
// A2c §2.4: playlist-collection discovery — provenance key first so a user
|
||||
// rename can't spawn duplicates on the next scan.
|
||||
func (q *Queries) GetCollectionByImportedSeries(ctx context.Context, arg GetCollectionByImportedSeriesParams) (Collections, error) {
|
||||
row := q.db.QueryRow(ctx, GetCollectionByImportedSeries, arg.UserID, arg.ImportedFromSeries)
|
||||
var i Collections
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.Color,
|
||||
&i.Icon,
|
||||
&i.AutoAssignRules,
|
||||
&i.ViewSettings,
|
||||
&i.ShowOnDashboard,
|
||||
&i.QueryType,
|
||||
&i.Priority,
|
||||
&i.IsSystemCollection,
|
||||
&i.CreatedAt,
|
||||
&i.DeletedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const GetCollectionByUserAndName = `-- name: GetCollectionByUserAndName :one
|
||||
SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at FROM collections
|
||||
WHERE user_id = $1 AND name = $2 AND deleted_at IS NULL
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
type GetCollectionByUserAndNameParams struct {
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetCollectionByUserAndName(ctx context.Context, arg GetCollectionByUserAndNameParams) (Collections, error) {
|
||||
row := q.db.QueryRow(ctx, GetCollectionByUserAndName, arg.UserID, arg.Name)
|
||||
var i Collections
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Name,
|
||||
&i.Description,
|
||||
&i.Color,
|
||||
&i.Icon,
|
||||
&i.AutoAssignRules,
|
||||
&i.ViewSettings,
|
||||
&i.ShowOnDashboard,
|
||||
&i.QueryType,
|
||||
&i.Priority,
|
||||
&i.IsSystemCollection,
|
||||
&i.CreatedAt,
|
||||
&i.DeletedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const GetCollectionItems = `-- name: GetCollectionItems :many
|
||||
SELECT ci.id, ci.collection_id, ci.media_item_id, ci.added_at, ci.added_by_user_id, ci.excluded, ci.position, mi.title, mi.author, mi.cover_image_path, mi.library_id, mi.format_group
|
||||
FROM collection_items ci
|
||||
@@ -2831,7 +2954,7 @@ JOIN media_items mi ON ci.media_item_id = mi.id
|
||||
WHERE ci.collection_id = $1
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
ORDER BY ci.added_at DESC
|
||||
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC
|
||||
`
|
||||
|
||||
type GetCollectionItemsRow struct {
|
||||
@@ -2850,6 +2973,9 @@ type GetCollectionItemsRow struct {
|
||||
}
|
||||
|
||||
// Get collection items
|
||||
// A2c: position-first ordering (playlist reading order); un-positioned
|
||||
// legacy items keep added_at ASC after the positioned tail — NOT DESC,
|
||||
// which walked a playlist backwards.
|
||||
func (q *Queries) GetCollectionItems(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionItemsRow, error) {
|
||||
rows, err := q.db.Query(ctx, GetCollectionItems, collectionID)
|
||||
if err != nil {
|
||||
@@ -2884,32 +3010,49 @@ func (q *Queries) GetCollectionItems(ctx context.Context, collectionID pgtype.UU
|
||||
}
|
||||
|
||||
const GetCollectionItemsForDashboard = `-- name: GetCollectionItemsForDashboard :many
|
||||
SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi
|
||||
SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, rp.percentage AS viewer_percentage FROM media_items mi
|
||||
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
|
||||
WHERE ci.collection_id = $1
|
||||
AND ($2::uuid IS NULL OR mi.library_id = $2::uuid)
|
||||
LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = $1::uuid
|
||||
WHERE ci.collection_id = $2
|
||||
AND ($3::uuid IS NULL OR mi.library_id = $3::uuid)
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND (ci.excluded IS NOT TRUE)
|
||||
ORDER BY ci.added_at DESC
|
||||
LIMIT $3
|
||||
AND ($4::boolean IS NOT TRUE OR COALESCE(rp.percentage, 0) < 0.999)
|
||||
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC
|
||||
LIMIT $5
|
||||
`
|
||||
|
||||
type GetCollectionItemsForDashboardParams struct {
|
||||
ViewerUserID pgtype.UUID `db:"viewer_user_id" json:"viewer_user_id"`
|
||||
CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"`
|
||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||
HideFinished pgtype.Bool `db:"hide_finished" json:"hide_finished"`
|
||||
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
||||
}
|
||||
|
||||
type GetCollectionItemsForDashboardRow struct {
|
||||
MediaItems MediaItems `db:"media_items" json:"media_items"`
|
||||
MediaItems MediaItems `db:"media_items" json:"media_items"`
|
||||
ViewerPercentage pgtype.Float8 `db:"viewer_percentage" json:"viewer_percentage"`
|
||||
}
|
||||
|
||||
// The excluded filter lives here (was: callers filtered ci.excluded in
|
||||
// Go over a 69-field row + a 45-field converter to MediaItems; embed
|
||||
// returns the real row so neither exists).
|
||||
// A2c: position-first ordering; optional hide_finished — when the flag is
|
||||
// set AND a viewer is supplied, items the viewer finished (percentage
|
||||
// >= 0.999) drop out, turning the section into the reading queue head.
|
||||
// viewer_user_id NULL = no progress filter (never-finished semantics for
|
||||
// anonymous/legacy callers is NOT implied: hide_finished without a viewer
|
||||
// is a caller bug and shows everything).
|
||||
func (q *Queries) GetCollectionItemsForDashboard(ctx context.Context, arg GetCollectionItemsForDashboardParams) ([]GetCollectionItemsForDashboardRow, error) {
|
||||
rows, err := q.db.Query(ctx, GetCollectionItemsForDashboard, arg.CollectionID, arg.LibraryID, arg.Limit)
|
||||
rows, err := q.db.Query(ctx, GetCollectionItemsForDashboard,
|
||||
arg.ViewerUserID,
|
||||
arg.CollectionID,
|
||||
arg.LibraryID,
|
||||
arg.HideFinished,
|
||||
arg.Limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2992,6 +3135,138 @@ func (q *Queries) GetCollectionItemsForDashboard(ctx context.Context, arg GetCol
|
||||
&i.MediaItems.OpfIdentifier,
|
||||
&i.MediaItems.OpfUuid,
|
||||
&i.MediaItems.HashConfidence,
|
||||
&i.ViewerPercentage,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const GetCollectionUpNext = `-- name: GetCollectionUpNext :many
|
||||
SELECT mi.id, mi.library_id, mi.title, mi.subtitle, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.narrators, mi.authors, mi.authors_search, mi.publishers, mi.publishers_search, mi.abridged, mi.explicit, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.audio_metadata, mi.tags_search, mi.contributors_search, mi.narrators_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, rp.percentage AS viewer_percentage FROM media_items mi
|
||||
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
|
||||
LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = $1::uuid
|
||||
WHERE ci.collection_id = $2
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND (ci.excluded IS NOT TRUE)
|
||||
AND COALESCE(rp.percentage, 0) < 0.999
|
||||
AND ($3::uuid[] IS NULL OR mi.library_id = ANY($3::uuid[]))
|
||||
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC
|
||||
LIMIT $4
|
||||
`
|
||||
|
||||
type GetCollectionUpNextParams struct {
|
||||
ViewerUserID pgtype.UUID `db:"viewer_user_id" json:"viewer_user_id"`
|
||||
CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"`
|
||||
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
|
||||
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
||||
}
|
||||
|
||||
type GetCollectionUpNextRow struct {
|
||||
MediaItems MediaItems `db:"media_items" json:"media_items"`
|
||||
ViewerPercentage pgtype.Float8 `db:"viewer_percentage" json:"viewer_percentage"`
|
||||
}
|
||||
|
||||
// A2c §3.4: the collection's reading queue — first N un-finished items in
|
||||
// position order. Strict playlist semantics: finished items drop out as
|
||||
// they complete, so the head IS "up next". Library visibility is enforced
|
||||
// by the family-lens array param (never NULL for user-facing callers).
|
||||
func (q *Queries) GetCollectionUpNext(ctx context.Context, arg GetCollectionUpNextParams) ([]GetCollectionUpNextRow, error) {
|
||||
rows, err := q.db.Query(ctx, GetCollectionUpNext,
|
||||
arg.ViewerUserID,
|
||||
arg.CollectionID,
|
||||
arg.VisibleLibraryIds,
|
||||
arg.Limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []GetCollectionUpNextRow{}
|
||||
for rows.Next() {
|
||||
var i GetCollectionUpNextRow
|
||||
if err := rows.Scan(
|
||||
&i.MediaItems.ID,
|
||||
&i.MediaItems.LibraryID,
|
||||
&i.MediaItems.Title,
|
||||
&i.MediaItems.Subtitle,
|
||||
&i.MediaItems.Author,
|
||||
&i.MediaItems.Isbn,
|
||||
&i.MediaItems.Description,
|
||||
&i.MediaItems.FilePath,
|
||||
&i.MediaItems.FileSize,
|
||||
&i.MediaItems.MimeType,
|
||||
&i.MediaItems.CoverImagePath,
|
||||
&i.MediaItems.Series,
|
||||
&i.MediaItems.SeriesNumber,
|
||||
&i.MediaItems.Tags,
|
||||
&i.MediaItems.Narrators,
|
||||
&i.MediaItems.Authors,
|
||||
&i.MediaItems.AuthorsSearch,
|
||||
&i.MediaItems.Publishers,
|
||||
&i.MediaItems.PublishersSearch,
|
||||
&i.MediaItems.Abridged,
|
||||
&i.MediaItems.Explicit,
|
||||
&i.MediaItems.Asin,
|
||||
&i.MediaItems.DatePublished,
|
||||
&i.MediaItems.Publisher,
|
||||
&i.MediaItems.Contributors,
|
||||
&i.MediaItems.Language,
|
||||
&i.MediaItems.Edition,
|
||||
&i.MediaItems.PageCount,
|
||||
&i.MediaItems.Genre,
|
||||
&i.MediaItems.CopyrightYear,
|
||||
&i.MediaItems.GoodreadsID,
|
||||
&i.MediaItems.OpenlibraryID,
|
||||
&i.MediaItems.GoogleBooksID,
|
||||
&i.MediaItems.AddedByAdminID,
|
||||
&i.MediaItems.CreatedAt,
|
||||
&i.MediaItems.ImportedAt,
|
||||
&i.MediaItems.UpdatedAt,
|
||||
&i.MediaItems.FormatGroup,
|
||||
&i.MediaItems.FormatMimetype,
|
||||
&i.MediaItems.IsReflowable,
|
||||
&i.MediaItems.HasFixedLayout,
|
||||
&i.MediaItems.TotalCharacters,
|
||||
&i.MediaItems.ChapterCount,
|
||||
&i.MediaItems.EntitlementID,
|
||||
&i.MediaItems.RevisionNumber,
|
||||
&i.MediaItems.KoboContentID,
|
||||
&i.MediaItems.KoboMetadata,
|
||||
&i.MediaItems.MangaType,
|
||||
&i.MediaItems.ReadingDirection,
|
||||
&i.MediaItems.SeriesCount,
|
||||
&i.MediaItems.Volume,
|
||||
&i.MediaItems.Imprint,
|
||||
&i.MediaItems.AgeRating,
|
||||
&i.MediaItems.WebUrl,
|
||||
&i.MediaItems.StoryArc,
|
||||
&i.MediaItems.IsBlackAndWhite,
|
||||
&i.MediaItems.MetadataNotes,
|
||||
&i.MediaItems.CommunityRating,
|
||||
&i.MediaItems.AlternateInfo,
|
||||
&i.MediaItems.ScanInformation,
|
||||
&i.MediaItems.Summary,
|
||||
&i.MediaItems.MetadataOverrides,
|
||||
&i.MediaItems.MissingScanCount,
|
||||
&i.MediaItems.ArchivedAt,
|
||||
&i.MediaItems.ChapterMetadata,
|
||||
&i.MediaItems.LibraryTypeName,
|
||||
&i.MediaItems.AudioMetadata,
|
||||
&i.MediaItems.TagsSearch,
|
||||
&i.MediaItems.ContributorsSearch,
|
||||
&i.MediaItems.NarratorsSearch,
|
||||
&i.MediaItems.FileSha256,
|
||||
&i.MediaItems.OpfIdentifier,
|
||||
&i.MediaItems.OpfUuid,
|
||||
&i.MediaItems.HashConfidence,
|
||||
&i.ViewerPercentage,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -8614,12 +8889,15 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (GetUs
|
||||
const GetUserCollectionsForDashboard = `-- name: GetUserCollectionsForDashboard :many
|
||||
SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.view_settings, c.show_on_dashboard, c.query_type, c.priority, c.is_system_collection, c.created_at, c.deleted_at FROM collections c
|
||||
WHERE c.user_id = $1
|
||||
AND c.show_on_dashboard = true
|
||||
AND c.is_system_collection = false
|
||||
AND c.deleted_at IS NULL
|
||||
ORDER BY priority ASC
|
||||
`
|
||||
|
||||
// A2c (2026-10-19): user collections are dashboard-ELIGIBLE BY DEFAULT —
|
||||
// show_on_dashboard gated them forever-false (no writer ever set it on
|
||||
// user rows), and hiding is the dashboard-prefs job (hidden_collections).
|
||||
// The flag remains system-section provisioning state only.
|
||||
func (q *Queries) GetUserCollectionsForDashboard(ctx context.Context, userID pgtype.UUID) ([]Collections, error) {
|
||||
rows, err := q.db.Query(ctx, GetUserCollectionsForDashboard, userID)
|
||||
if err != nil {
|
||||
@@ -12121,6 +12399,26 @@ func (q *Queries) RemoveBookFromKoboShelf(ctx context.Context, arg RemoveBookFro
|
||||
return err
|
||||
}
|
||||
|
||||
const ReorderCollectionItems = `-- name: ReorderCollectionItems :exec
|
||||
UPDATE collection_items AS ci
|
||||
SET position = d.ord
|
||||
FROM unnest($2::uuid[]) WITH ORDINALITY AS d(media_item_id, ord)
|
||||
WHERE ci.collection_id = $1::uuid
|
||||
AND ci.media_item_id = d.media_item_id
|
||||
`
|
||||
|
||||
type ReorderCollectionItemsParams struct {
|
||||
CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"`
|
||||
MediaItemIds []pgtype.UUID `db:"media_item_ids" json:"media_item_ids"`
|
||||
}
|
||||
|
||||
// A2c: full-order rewrite for the drag-to-reorder endpoint. The payload is
|
||||
// the complete ordered id list; positions become 1..n in payload order.
|
||||
func (q *Queries) ReorderCollectionItems(ctx context.Context, arg ReorderCollectionItemsParams) error {
|
||||
_, err := q.db.Exec(ctx, ReorderCollectionItems, arg.CollectionID, arg.MediaItemIds)
|
||||
return err
|
||||
}
|
||||
|
||||
const ReparentMediaItemChildren = `-- name: ReparentMediaItemChildren :exec
|
||||
SELECT reparent_media_item_children($1::uuid, $2::uuid)
|
||||
`
|
||||
@@ -13711,6 +14009,22 @@ func (q *Queries) UpdateCollectionRulesJSON(ctx context.Context, arg UpdateColle
|
||||
return i, err
|
||||
}
|
||||
|
||||
const UpdateCollectionViewSettings = `-- name: UpdateCollectionViewSettings :exec
|
||||
UPDATE collections SET view_settings = $2, updated_at = NOW() WHERE id = $1
|
||||
`
|
||||
|
||||
type UpdateCollectionViewSettingsParams struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
ViewSettings []byte `db:"view_settings" json:"view_settings"`
|
||||
}
|
||||
|
||||
// A2c §2.4: provenance + playlist flags on an existing collection without
|
||||
// touching the rest of the row.
|
||||
func (q *Queries) UpdateCollectionViewSettings(ctx context.Context, arg UpdateCollectionViewSettingsParams) error {
|
||||
_, err := q.db.Exec(ctx, UpdateCollectionViewSettings, arg.ID, arg.ViewSettings)
|
||||
return err
|
||||
}
|
||||
|
||||
const UpdateDashboardPreferences = `-- name: UpdateDashboardPreferences :one
|
||||
UPDATE user_dashboard_preferences
|
||||
SET hidden_collections = $2,
|
||||
|
||||
@@ -2328,10 +2328,15 @@ DELETE FROM device_file_aliases WHERE id = $1;
|
||||
|
||||
-- Create collection
|
||||
-- name: CreateCollection :one
|
||||
INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *;
|
||||
|
||||
-- A2c §2.4: provenance + playlist flags on an existing collection without
|
||||
-- touching the rest of the row.
|
||||
-- name: UpdateCollectionViewSettings :exec
|
||||
UPDATE collections SET view_settings = $2, updated_at = NOW() WHERE id = $1;
|
||||
|
||||
-- Get collection
|
||||
-- name: GetCollection :one
|
||||
SELECT * FROM collections WHERE id = $1;
|
||||
@@ -2381,11 +2386,46 @@ VALUES ($1, $2, $3)
|
||||
ON CONFLICT (collection_id, media_item_id) DO NOTHING
|
||||
RETURNING *;
|
||||
|
||||
-- A2c: playlist upsert — position set ONLY on insert, so rescans and
|
||||
-- re-adds never clobber the user's manual drag ordering.
|
||||
-- name: AddBookToCollectionOrdered :one
|
||||
INSERT INTO collection_items (collection_id, media_item_id, added_by_user_id, position)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (collection_id, media_item_id) DO NOTHING
|
||||
RETURNING *;
|
||||
|
||||
-- A2c: full-order rewrite for the drag-to-reorder endpoint. The payload is
|
||||
-- the complete ordered id list; positions become 1..n in payload order.
|
||||
-- name: ReorderCollectionItems :exec
|
||||
UPDATE collection_items AS ci
|
||||
SET position = d.ord
|
||||
FROM unnest(sqlc.arg('media_item_ids')::uuid[]) WITH ORDINALITY AS d(media_item_id, ord)
|
||||
WHERE ci.collection_id = sqlc.arg('collection_id')::uuid
|
||||
AND ci.media_item_id = d.media_item_id;
|
||||
|
||||
-- A2c §2.4: playlist-collection discovery — provenance key first so a user
|
||||
-- rename can't spawn duplicates on the next scan.
|
||||
-- name: GetCollectionByImportedSeries :one
|
||||
SELECT * FROM collections
|
||||
WHERE user_id = sqlc.arg('user_id')
|
||||
AND view_settings->>'imported_from_series' = sqlc.arg('imported_from_series')::text
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: GetCollectionByUserAndName :one
|
||||
SELECT * FROM collections
|
||||
WHERE user_id = $1 AND name = $2 AND deleted_at IS NULL
|
||||
LIMIT 1;
|
||||
|
||||
-- Remove book from collection
|
||||
-- name: RemoveBookFromCollection :exec
|
||||
DELETE FROM collection_items WHERE collection_id = $1 AND media_item_id = $2;
|
||||
|
||||
-- Get collection items
|
||||
-- A2c: position-first ordering (playlist reading order); un-positioned
|
||||
-- legacy items keep added_at ASC after the positioned tail — NOT DESC,
|
||||
-- which walked a playlist backwards.
|
||||
-- name: GetCollectionItems :many
|
||||
SELECT ci.*, mi.title, mi.author, mi.cover_image_path, mi.library_id, mi.format_group
|
||||
FROM collection_items ci
|
||||
@@ -2393,7 +2433,7 @@ JOIN media_items mi ON ci.media_item_id = mi.id
|
||||
WHERE ci.collection_id = $1
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
ORDER BY ci.added_at DESC;
|
||||
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC;
|
||||
|
||||
-- Get collections for book
|
||||
-- name: GetCollectionsForBook :many
|
||||
@@ -2710,9 +2750,12 @@ WHERE user_id = $1
|
||||
ORDER BY priority ASC;
|
||||
|
||||
-- name: GetUserCollectionsForDashboard :many
|
||||
-- A2c (2026-10-19): user collections are dashboard-ELIGIBLE BY DEFAULT —
|
||||
-- show_on_dashboard gated them forever-false (no writer ever set it on
|
||||
-- user rows), and hiding is the dashboard-prefs job (hidden_collections).
|
||||
-- The flag remains system-section provisioning state only.
|
||||
SELECT c.* FROM collections c
|
||||
WHERE c.user_id = $1
|
||||
AND c.show_on_dashboard = true
|
||||
AND c.is_system_collection = false
|
||||
AND c.deleted_at IS NULL
|
||||
ORDER BY priority ASC;
|
||||
@@ -2828,16 +2871,47 @@ LIMIT sqlc.narg('limit');
|
||||
-- The excluded filter lives here (was: callers filtered ci.excluded in
|
||||
-- Go over a 69-field row + a 45-field converter to MediaItems; embed
|
||||
-- returns the real row so neither exists).
|
||||
SELECT sqlc.embed(mi) FROM media_items mi
|
||||
-- A2c: position-first ordering; optional hide_finished — when the flag is
|
||||
-- set AND a viewer is supplied, items the viewer finished (percentage
|
||||
-- >= 0.999) drop out, turning the section into the reading queue head.
|
||||
-- viewer_user_id NULL = no progress filter (never-finished semantics for
|
||||
-- anonymous/legacy callers is NOT implied: hide_finished without a viewer
|
||||
-- is a caller bug and shows everything).
|
||||
SELECT sqlc.embed(mi), rp.percentage AS viewer_percentage FROM media_items mi
|
||||
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
|
||||
LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = sqlc.narg('viewer_user_id')::uuid
|
||||
WHERE ci.collection_id = sqlc.narg('collection_id')
|
||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND (ci.excluded IS NOT TRUE)
|
||||
ORDER BY ci.added_at DESC
|
||||
AND (sqlc.narg('hide_finished')::boolean IS NOT TRUE OR COALESCE(rp.percentage, 0) < 0.999)
|
||||
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- A2c §3.4: the collection's reading queue — first N un-finished items in
|
||||
-- position order. Strict playlist semantics: finished items drop out as
|
||||
-- they complete, so the head IS "up next". Library visibility is enforced
|
||||
-- by the family-lens array param (never NULL for user-facing callers).
|
||||
-- name: GetCollectionUpNext :many
|
||||
SELECT sqlc.embed(mi), rp.percentage AS viewer_percentage FROM media_items mi
|
||||
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
|
||||
LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = sqlc.narg('viewer_user_id')::uuid
|
||||
WHERE ci.collection_id = sqlc.narg('collection_id')
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND (ci.excluded IS NOT TRUE)
|
||||
AND COALESCE(rp.percentage, 0) < 0.999
|
||||
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- A2c §3.5: distinguish "playlist complete" from "genuinely empty" — the
|
||||
-- dashboard renders a completion marker when hide_finished filtered
|
||||
-- everything out of a non-empty collection.
|
||||
-- name: CountCollectionItems :one
|
||||
SELECT COUNT(*) FROM collection_items WHERE collection_id = $1;
|
||||
|
||||
-- Smart ("dynamic") collections: rules are evaluated LIVE at render, so
|
||||
-- membership comes from the candidate book set instead of collection_items.
|
||||
-- The set is scoped to the requesting user's visible libraries (family
|
||||
|
||||
@@ -58,6 +58,7 @@ type SettingDefault struct {
|
||||
var SettingDefaults = []SettingDefault{
|
||||
{Key: "scan_poll_interval_seconds", Value: "60", Type: SettingTypeInt, Min: "1", Max: "3600", Category: "scanner", Group: "Scanning", Description: "How often to scan all libraries (seconds)"},
|
||||
{Key: "auto_scan_enabled", Value: "true", Type: SettingTypeBool, Category: "scanner", Group: "Scanning", Description: "Whether auto-scanning is enabled system-wide"},
|
||||
{Key: "auto_playlist_collections", Value: "true", Type: SettingTypeBool, Category: "scanner", Group: "Scanning", Description: "Auto-create curated playlist collections from sidecar series marked as reading orders"},
|
||||
{Key: "default_timezone", Value: "UTC", Type: SettingTypeString, Category: "general", Group: "System Defaults", Description: "System default timezone"},
|
||||
{Key: "session_duration_seconds", Value: "604800", Type: SettingTypeInt, Min: "300", Max: "31536000", Category: "security", Group: "Session", Description: "How long a login session stays valid"},
|
||||
{Key: "password_min_length", Value: "8", Type: SettingTypeInt, Min: "1", Max: "128", Category: "security", Group: "Password Quality", Description: "Minimum password length"},
|
||||
@@ -189,6 +190,11 @@ func (r *SettingsRegistry) ScanPollInterval() time.Duration {
|
||||
// AutoScanEnabled reports whether auto-scanning is on.
|
||||
func (r *SettingsRegistry) AutoScanEnabled() bool { return r.getBool("auto_scan_enabled") }
|
||||
|
||||
// AutoPlaylistCollections reports whether the scanner should turn
|
||||
// obviously-playlist sidecar series ("The Witcher (Reading Order)") into
|
||||
// curated static collections (A2c §2.4).
|
||||
func (r *SettingsRegistry) AutoPlaylistCollections() bool { return r.getBool("auto_playlist_collections") }
|
||||
|
||||
// RegistrationEnabled reports whether public signup is allowed. Admin user
|
||||
// creation is never affected — only self-service registration rides this
|
||||
// flag; handlers additionally grant the first-user exception (see
|
||||
|
||||
Reference in new issue
Block a user