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
@@ -23,6 +23,7 @@ import (
|
||||
_ "image/png"
|
||||
"io"
|
||||
"io/fs"
|
||||
"math"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -268,6 +269,136 @@ func (s *MediaScanner) GetAutoScanEnabled() bool {
|
||||
return strings.ToLower(setting) == "true"
|
||||
}
|
||||
|
||||
// GetAutoPlaylistCollections mirrors GetAutoScanEnabled for the A2c
|
||||
// playlist-collection toggle. Defaults ON: the feature only creates
|
||||
// deletable curated collections from explicit "(Reading Order)" markers.
|
||||
func (s *MediaScanner) GetAutoPlaylistCollections() bool {
|
||||
if cached, ok := s.settingsCache.Get("auto_playlist_collections"); ok {
|
||||
return strings.ToLower(cached) == "true"
|
||||
}
|
||||
if s.db == nil {
|
||||
return true
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
setting, err := s.db.GetSystemSetting(ctx, "auto_playlist_collections")
|
||||
if err != nil || setting == "" {
|
||||
return true
|
||||
}
|
||||
s.settingsCache.Set("auto_playlist_collections", setting)
|
||||
return strings.ToLower(setting) == "true"
|
||||
}
|
||||
|
||||
// syncPlaylistCollectionsAfterCreate turns a created item's playlist-marked
|
||||
// series entries (A2c §2.2) into curated static collections (§2.4):
|
||||
// upsert by imported_from_series provenance then name, add-if-missing with
|
||||
// position ONLY on insert, provenance in description + view_settings,
|
||||
// dashboard-eligible by default. Deleting a sidecar later deletes nothing —
|
||||
// the collection is user data from the moment it exists.
|
||||
func (s *MediaScanner) syncPlaylistCollectionsAfterCreate(ctx context.Context, itemID pgtype.UUID, entries []SeriesCandidate) {
|
||||
if len(entries) == 0 || s.db == nil || !s.GetAutoPlaylistCollections() {
|
||||
return
|
||||
}
|
||||
for _, entry := range entries {
|
||||
collection, created, err := s.upsertPlaylistCollection(ctx, entry)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: playlist collection sync failed for %q: %v\n", entry.Name, err)
|
||||
continue
|
||||
}
|
||||
position := cleanIntegerPosition(entry.Sequence)
|
||||
if _, err := s.db.AddBookToCollectionOrdered(ctx, database.AddBookToCollectionOrderedParams{
|
||||
CollectionID: collection.ID,
|
||||
MediaItemID: itemID,
|
||||
AddedByUserID: s.adminID,
|
||||
Position: pgtype.Int4{Int32: position, Valid: position > 0},
|
||||
}); err != nil {
|
||||
fmt.Printf("Warning: playlist collection add failed for %q: %v\n", entry.Name, err)
|
||||
continue
|
||||
}
|
||||
if created {
|
||||
fmt.Printf("Created playlist collection %q from sidecar series (safe to edit or delete)\n", entry.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// upsertPlaylistCollection finds the playlist collection for a sidecar
|
||||
// series: provenance key first (survives renames), name second.
|
||||
func (s *MediaScanner) upsertPlaylistCollection(ctx context.Context, entry SeriesCandidate) (database.Collections, bool, error) {
|
||||
owner := s.adminID
|
||||
if collection, err := s.db.GetCollectionByImportedSeries(ctx, database.GetCollectionByImportedSeriesParams{
|
||||
UserID: owner,
|
||||
ImportedFromSeries: entry.Name,
|
||||
}); err == nil {
|
||||
return collection, false, nil
|
||||
}
|
||||
if collection, err := s.db.GetCollectionByUserAndName(ctx, database.GetCollectionByUserAndNameParams{
|
||||
UserID: owner,
|
||||
Name: entry.Name,
|
||||
}); err == nil {
|
||||
// Name match, no provenance: mark it so future scans find it by
|
||||
// provenance and don't spawn a duplicate after a rename.
|
||||
s.markPlaylistProvenance(ctx, collection.ID, entry.Name)
|
||||
return collection, false, nil
|
||||
}
|
||||
|
||||
description := fmt.Sprintf("Imported from metadata.json sidecar (series %q) — safe to edit or delete.", entry.Name)
|
||||
viewSettings, _ := json.Marshal(map[string]interface{}{
|
||||
"show_up_next": true,
|
||||
"imported_from_series": entry.Name,
|
||||
})
|
||||
created, err := s.db.CreateCollection(ctx, database.CreateCollectionParams{
|
||||
UserID: owner,
|
||||
Name: entry.Name,
|
||||
Description: pgtype.Text{String: description, Valid: true},
|
||||
AutoAssignRules: nil, // jsonb NULL — an empty []byte encodes as bytea and 22P02s
|
||||
ViewSettings: viewSettings,
|
||||
ShowOnDashboard: pgtype.Bool{Bool: true, Valid: true}, // playlists land on the dashboard by design; hide via dashboard prefs
|
||||
})
|
||||
if err != nil {
|
||||
return database.Collections{}, false, err
|
||||
}
|
||||
return created, true, nil
|
||||
}
|
||||
|
||||
func (s *MediaScanner) markPlaylistProvenance(ctx context.Context, collectionID pgtype.UUID, seriesName string) {
|
||||
collection, err := s.db.GetCollection(ctx, collectionID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var settings map[string]interface{}
|
||||
if len(collection.ViewSettings) > 0 {
|
||||
_ = json.Unmarshal(collection.ViewSettings, &settings)
|
||||
}
|
||||
if settings == nil {
|
||||
settings = map[string]interface{}{}
|
||||
}
|
||||
settings["imported_from_series"] = seriesName
|
||||
settings["show_up_next"] = true
|
||||
b, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.db.UpdateCollectionViewSettings(ctx, database.UpdateCollectionViewSettingsParams{
|
||||
ID: collectionID,
|
||||
ViewSettings: b,
|
||||
})
|
||||
}
|
||||
|
||||
// cleanIntegerPosition accepts only CLEAN integer sequences ("3") as a
|
||||
// playlist position; decimals ("1.5") and lettered ("1a") stay un-positioned
|
||||
// (ordering falls back to added_at) rather than guessing.
|
||||
func cleanIntegerPosition(sequence string) int32 {
|
||||
sequence = strings.TrimSpace(sequence)
|
||||
if sequence == "" {
|
||||
return 0
|
||||
}
|
||||
f, err := strconv.ParseFloat(sequence, 64)
|
||||
if err != nil || f != math.Trunc(f) || f < 1 || f > 2147483647 {
|
||||
return 0
|
||||
}
|
||||
return int32(f)
|
||||
}
|
||||
|
||||
func (s *MediaScanner) SetAdminID(adminID pgtype.UUID) {
|
||||
s.adminID = adminID
|
||||
}
|
||||
@@ -1139,6 +1270,9 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
|
||||
}
|
||||
|
||||
s.newItems++
|
||||
|
||||
// A2c §2.4: obvious playlist sidecar series become curated collections.
|
||||
s.syncPlaylistCollectionsAfterCreate(ctx, createdItem.ID, metadata.PlaylistSeries)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user