mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-18 07:30:29 +00:00
* fix(playlist): preserve smart playlist counters on re-import (#5907) * perf(playlist): skip re-importing unchanged NSP files (#5907) * feat(playlist): also store content hash for M3U imports (unused for now) * fix(playlist): return stored record when skipping unchanged NSP import Skipping before copying the stored identity broke the ImportFile(sync=false) contract: callers received an ID-less playlist and the requested Sync change was silently dropped. * refactor(playlist): hash imports once at the caller; protect smart counters in Put Move content hashing out of both parsers into the code that owns the file (parsePlaylist and ImportFile), removing the NSP double-buffer and the duplicated hashing idiom. Put now drops song_count/duration/size for smart playlists (PostMapArgs), disarming the counter-zeroing trap for all callers. * fix(playlist): invalidate imported hash when rules are edited via API Without this, a rules edit through the REST API kept the stored file hash, so every scan skipped the unchanged file and never restored the file-backed rules while sync was on. * test(playlist): verify smart counters survive a re-import, end to end The existing Put test seeds the stored counters with a raw SQL update, so it pins the guard in PostMapArgs but not the pipeline around it. This test drives the counters through a real evaluation instead: it saves a smart playlist, reads it with GetWithTracks to populate song_count/duration/size, then saves the playlist the way the scanner rebuilds it after parsing the .nsp file, with the counters back at zero. Both routes fail without the guard, and the new one covers the exact sequence reported in #5907. Test taken from #5970, which diagnosed the same root cause independently. Co-authored-by: Junker der Provinz <133605895+junkerderprovinz@users.noreply.github.com> * test(playlist): build the service with artwork.NewUploader The artwork pipeline in #5847 replaced core.NewImageUploadService() with artwork.NewUploader(ds) and updated every call site it could see. The five call sites this branch adds were written against the old constructor, so the merge applied cleanly but left the package uncompilable. * fix(db): re-stamp the imported_hash migration after the master merge Master gained three migrations while this branch was open, the newest being 20260816180040. The original 20260808200333 stamp now sorts before them, so any database already upgraded past that point would skip this migration entirely and never get the imported_hash column. Same SQL, current timestamp. * refactor(playlist): hash imported playlists with xxh3 and the id encoding ImportedHash is a change detector, not a security boundary, so it does not need a cryptographic digest. xxh3 is already a direct dependency and is used the same way to fingerprint files in the artwork image store. Encoding the 128-bit digest with id.Encode stores it in the same 22-char base62 form as every other id in the schema, down from 64 hex chars. No migration is needed: the imported_hash column has not shipped in a release, so no database holds a value in the old format. * refactor(playlist): extract the imported-playlist fingerprint helper Both import paths encoded the hash inline, so how a playlist file is fingerprinted lived in two places. A third import path that encoded it differently would silently never match the stored value, turning the unchanged-file skip into a no-op. --------- Co-authored-by: Junker der Provinz <133605895+junkerderprovinz@users.noreply.github.com>
203 lines
6.8 KiB
Go
203 lines
6.8 KiB
Go
package playlists
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"reflect"
|
|
"strings"
|
|
|
|
"github.com/deluan/rest"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/criteria"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
"github.com/navidrome/navidrome/utils/slice"
|
|
)
|
|
|
|
// --- REST adapter (follows Share/Library pattern) ---
|
|
|
|
func (s *playlists) NewRepository(ctx context.Context) rest.Repository {
|
|
return &playlistRepositoryWrapper{
|
|
ctx: ctx,
|
|
PlaylistRepository: s.ds.Playlist(ctx),
|
|
service: s,
|
|
}
|
|
}
|
|
|
|
// playlistRepositoryWrapper wraps the playlist repository as a thin REST-to-service adapter.
|
|
// It satisfies rest.Repository through the embedded PlaylistRepository (via ResourceRepository),
|
|
// and rest.Persistable by delegating to service methods for all mutations.
|
|
type playlistRepositoryWrapper struct {
|
|
model.PlaylistRepository
|
|
ctx context.Context
|
|
service *playlists
|
|
}
|
|
|
|
func (r *playlistRepositoryWrapper) Save(entity any) (string, error) {
|
|
return r.service.savePlaylist(r.ctx, entity.(*model.Playlist))
|
|
}
|
|
|
|
func (r *playlistRepositoryWrapper) Update(id string, entity any, cols ...string) error {
|
|
return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist), cols...)
|
|
}
|
|
|
|
func (r *playlistRepositoryWrapper) Delete(id string) error {
|
|
err := r.service.Delete(r.ctx, id)
|
|
switch {
|
|
case errors.Is(err, model.ErrNotFound):
|
|
return rest.ErrNotFound
|
|
case errors.Is(err, model.ErrNotAuthorized):
|
|
return rest.ErrPermissionDenied
|
|
default:
|
|
return err
|
|
}
|
|
}
|
|
|
|
func (s *playlists) TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository {
|
|
repo := s.ds.Playlist(ctx)
|
|
tracks := repo.Tracks(playlistId, refreshSmartPlaylist)
|
|
if tracks == nil {
|
|
return nil
|
|
}
|
|
return tracks.(rest.Repository)
|
|
}
|
|
|
|
// savePlaylist creates a new playlist, assigning the owner from context.
|
|
// Only Name, Comment, Public, and Rules are user-settable via the REST API.
|
|
func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (string, error) {
|
|
usr, _ := request.UserFrom(ctx)
|
|
pls.OwnerID = usr.ID
|
|
pls.ID = "" // Force new creation
|
|
pls.Path = "" // Server-managed (M3U file path)
|
|
pls.Sync = false // Server-managed (M3U sync flag)
|
|
pls.UploadedImage = "" // Managed by image upload endpoint
|
|
pls.ExternalImageURL = "" // Managed by M3U import / plugins only
|
|
pls.EvaluatedAt = nil // Server-managed
|
|
err := s.ds.Playlist(ctx).Put(pls)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return pls.ID, nil
|
|
}
|
|
|
|
// updatePlaylistEntity updates playlist metadata with permission checks.
|
|
// Used by the REST API wrapper.
|
|
//
|
|
// cols names the fields the client actually sent in the JSON body (extracted by
|
|
// rest.Put). When non-empty, fields outside cols are not considered changed and
|
|
// are left untouched — this prevents partial requests like bulk "Make Public"
|
|
// (body: {"public": true}) from wiping fields that just happen to be zero in
|
|
// the deserialized entity (see issue #5541). An empty cols means "treat the
|
|
// entity as a complete record" — preserved for callers that don't use the REST
|
|
// wrapper.
|
|
func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist, cols ...string) error {
|
|
current, err := s.checkWritable(ctx, id)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, model.ErrNotFound):
|
|
return rest.ErrNotFound
|
|
case errors.Is(err, model.ErrNotAuthorized):
|
|
return rest.ErrPermissionDenied
|
|
default:
|
|
return err
|
|
}
|
|
}
|
|
|
|
sent := sentFields(cols)
|
|
|
|
usr, _ := request.UserFrom(ctx)
|
|
ownerChanged := sent("ownerId") && entity.OwnerID != "" && entity.OwnerID != current.OwnerID
|
|
if !usr.IsAdmin && ownerChanged {
|
|
return rest.ErrPermissionDenied
|
|
}
|
|
|
|
nameChanged := sent("name") && entity.Name != current.Name
|
|
commentChanged := sent("comment") && entity.Comment != current.Comment
|
|
rulesChanged := sent("rules") && !rulesEqual(current.Rules, entity.Rules)
|
|
|
|
if nameChanged || commentChanged || ownerChanged || rulesChanged {
|
|
return s.applyContentUpdate(ctx, current, entity, sent,
|
|
nameChanged, commentChanged, ownerChanged, rulesChanged)
|
|
}
|
|
return s.applyFlagsOnly(ctx, current, entity, sent)
|
|
}
|
|
|
|
// applyContentUpdate handles updates that change at least one of name/comment/
|
|
// owner/rules. It goes through updateMetadata, which always bumps updatedAt
|
|
// (invalidating cached cover-art URLs). namePtr/commentPtr are nil when the
|
|
// field is absent from the request OR present-but-unchanged (so updateMetadata
|
|
// skips them); publicPtr is nil only when public is absent from the request
|
|
// (an idempotent public value is still forwarded).
|
|
func (s *playlists) applyContentUpdate(ctx context.Context, current, entity *model.Playlist,
|
|
sent func(string) bool, nameChanged, commentChanged, ownerChanged, rulesChanged bool,
|
|
) error {
|
|
if ownerChanged {
|
|
current.OwnerID = entity.OwnerID
|
|
}
|
|
if rulesChanged {
|
|
current.Rules = entity.Rules
|
|
current.EvaluatedAt = nil // force re-evaluation on next read
|
|
current.ImportedHash = "" // rules no longer match the source file; next scan must re-import it
|
|
}
|
|
if sent("sync") && current.Path != "" && current.Sync != entity.Sync {
|
|
current.Sync = entity.Sync
|
|
}
|
|
var namePtr, commentPtr *string
|
|
var publicPtr *bool
|
|
if nameChanged {
|
|
namePtr = &entity.Name
|
|
}
|
|
if commentChanged {
|
|
commentPtr = &entity.Comment
|
|
}
|
|
if sent("public") {
|
|
publicPtr = &entity.Public
|
|
}
|
|
return s.updateMetadata(ctx, s.ds, current, namePtr, commentPtr, publicPtr)
|
|
}
|
|
|
|
// applyFlagsOnly handles updates that only toggle sync/public — skips
|
|
// updatedAt so cover art URLs stay stable.
|
|
func (s *playlists) applyFlagsOnly(ctx context.Context, current, entity *model.Playlist,
|
|
sent func(string) bool,
|
|
) error {
|
|
var updateCols []string
|
|
if sent("sync") && current.Path != "" && current.Sync != entity.Sync {
|
|
current.Sync = entity.Sync
|
|
updateCols = append(updateCols, "sync")
|
|
}
|
|
if sent("public") && current.Public != entity.Public {
|
|
current.Public = entity.Public
|
|
updateCols = append(updateCols, "public")
|
|
}
|
|
if len(updateCols) == 0 {
|
|
return nil
|
|
}
|
|
return s.ds.Playlist(ctx).Put(current, updateCols...)
|
|
}
|
|
|
|
// sentFields returns a predicate that reports whether a JSON field was present
|
|
// in the request body. Matching is case-insensitive to mirror Go's json
|
|
// decoder, which populates struct fields from case-variant keys like
|
|
// {"Name":"x"} or {"OWNERID":"y"}. An empty cols list means "treat the entity
|
|
// as a full record" — every field is considered sent.
|
|
func sentFields(cols []string) func(string) bool {
|
|
if len(cols) == 0 {
|
|
return func(string) bool { return true }
|
|
}
|
|
set := slice.ToMap(cols, func(c string) (string, struct{}) { return strings.ToLower(c), struct{}{} })
|
|
return func(field string) bool {
|
|
_, ok := set[strings.ToLower(field)]
|
|
return ok
|
|
}
|
|
}
|
|
|
|
func rulesEqual(a, b *criteria.Criteria) bool {
|
|
if a == b {
|
|
return true
|
|
}
|
|
if a == nil || b == nil {
|
|
return false
|
|
}
|
|
return reflect.DeepEqual(a, b)
|
|
}
|