mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-18 07:30:29 +00:00
On Windows, filepath.Rel never returns when the base is a UNC share root (\\host\share) and the target is the same root with a trailing separator (golang/go#79784, fixed only in Go 1.28 by CL 786100). A user on Windows Server with MusicFolder='\\server\Music' (discussion #6232) sent CPU profiles showing 96% of CPU time in filepath.Rel, called from fromArtistFolder <- resolveArtistFolderPattern <- resolver.resolveArtist. For an album directly under the library root, loadArtistFolder's filepath.Dir returns '\\server\Music\', so the artwork worker spins forever and, because Worker.drain waits for the whole batch, the artist artwork pool stops. Add utils.RelPath, which normalizes a UNC volume root followed only by separators to the volume plus one separator before calling filepath.Rel. Both sides then compare equal and Rel returns '.' before reaching the loop. A bare '\\host\share' target under a deeper base now also gives '..' instead of an error. Drive-letter paths are left unchanged, and on non-Windows systems the helper behaves exactly like filepath.Rel. Use it at every production filepath.Rel call site: artist folder artwork, scanner library-relative paths, the watcher, playlist path resolution and PlaylistsPath matching, and the cache spread FS. Windows-only tests cover the UNC cases, with a timeout so a regression fails instead of hanging CI. Run under Wine, 6 of them fail with plain filepath.Rel (4 by timeout) and all pass with RelPath. The helper can be removed after moving to Go 1.28.
370 lines
12 KiB
Go
370 lines
12 KiB
Go
package playlists
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/bmatcuk/doublestar/v4"
|
|
"github.com/deluan/rest"
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/consts"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
"github.com/navidrome/navidrome/utils"
|
|
)
|
|
|
|
type Playlists interface {
|
|
// Reads
|
|
GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error)
|
|
Get(ctx context.Context, id string) (*model.Playlist, error)
|
|
GetWithTracks(ctx context.Context, id string) (*model.Playlist, error)
|
|
Tracks(ctx context.Context, id string) (model.PlaylistTrackRepository, error)
|
|
GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error)
|
|
|
|
// Mutations
|
|
Create(ctx context.Context, playlistId string, name string, ids []string) (string, error)
|
|
Delete(ctx context.Context, id string) error
|
|
Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error
|
|
|
|
// Track management
|
|
AddTracks(ctx context.Context, playlistID string, ids []string) (int, error)
|
|
InsertTracks(ctx context.Context, playlistID string, ids []string, pos int) (int, error)
|
|
AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error)
|
|
AddArtists(ctx context.Context, playlistID string, artistIds []string) (int, error)
|
|
AddDiscs(ctx context.Context, playlistID string, discs []model.DiscID) (int, error)
|
|
RemoveTracks(ctx context.Context, playlistID string, trackIds []string) error
|
|
ReorderTrack(ctx context.Context, playlistID string, pos int, newPos int) error
|
|
|
|
// Cover art
|
|
SetImage(ctx context.Context, playlistID string, reader io.Reader, ext string) error
|
|
RemoveImage(ctx context.Context, playlistID string) error
|
|
|
|
// Import
|
|
ImportFile(ctx context.Context, absolutePath string, sync bool) (*model.Playlist, error)
|
|
ImportFromFolder(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error)
|
|
ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error)
|
|
|
|
// REST adapters
|
|
Repository() rest.Repository[model.Playlist]
|
|
TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository[model.PlaylistTrack]
|
|
}
|
|
|
|
// ImageUploadService is a local interface satisfied by artwork.Uploader.
|
|
// Defined here to avoid an import cycle between core/artwork and core/playlists.
|
|
type ImageUploadService interface {
|
|
SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (filename string, err error)
|
|
RemoveImage(ctx context.Context, path string) error
|
|
EnqueueArtwork(ctx context.Context, entityType, entityID string)
|
|
}
|
|
|
|
type playlists struct {
|
|
ds model.DataStore
|
|
imgUpload ImageUploadService
|
|
repo *playlistRepositoryWrapper
|
|
}
|
|
|
|
func NewPlaylists(ds model.DataStore, imgUpload ImageUploadService) Playlists {
|
|
s := &playlists{ds: ds, imgUpload: imgUpload}
|
|
s.repo = &playlistRepositoryWrapper{PlaylistRepository: ds.Playlist(), service: s}
|
|
return s
|
|
}
|
|
|
|
func InPath(folder model.Folder) bool {
|
|
if conf.Server.PlaylistsPath == "" {
|
|
return true
|
|
}
|
|
rel, _ := utils.RelPath(folder.LibraryPath, folder.AbsolutePath())
|
|
for path := range strings.SplitSeq(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) {
|
|
if match, _ := doublestar.Match(path, rel); match {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// --- Read operations ---
|
|
|
|
func (s *playlists) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error) {
|
|
return s.ds.Playlist().GetAll(ctx, options...)
|
|
}
|
|
|
|
func (s *playlists) Get(ctx context.Context, id string) (*model.Playlist, error) {
|
|
return s.ds.Playlist().Get(ctx, id)
|
|
}
|
|
|
|
func (s *playlists) GetWithTracks(ctx context.Context, id string) (*model.Playlist, error) {
|
|
return s.ds.Playlist().GetWithTracks(ctx, id, true, false)
|
|
}
|
|
|
|
func (s *playlists) GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error) {
|
|
return s.ds.Playlist().GetPlaylists(ctx, mediaFileId)
|
|
}
|
|
|
|
// Tracks scopes a repository to one playlist's tracks, for callers that page or stream them rather
|
|
// than loading every one like GetWithTracks. Gets first because PlaylistRepository.Tracks discards
|
|
// its error behind a nil (and warns), and this is probed with ids that are usually not playlists.
|
|
func (s *playlists) Tracks(ctx context.Context, id string) (model.PlaylistTrackRepository, error) {
|
|
repo := s.ds.Playlist()
|
|
if _, err := repo.Get(ctx, id); err != nil {
|
|
return nil, err
|
|
}
|
|
tracks := repo.Tracks(ctx, id, true)
|
|
if tracks == nil {
|
|
return nil, model.ErrNotFound
|
|
}
|
|
return tracks, nil
|
|
}
|
|
|
|
// --- Mutation operations ---
|
|
|
|
// Create creates a new playlist (when name is provided) or replaces tracks on an existing
|
|
// playlist (when playlistId is provided). This matches the Subsonic createPlaylist semantics.
|
|
func (s *playlists) Create(ctx context.Context, playlistId string, name string, ids []string) (string, error) {
|
|
usr, _ := request.UserFrom(ctx)
|
|
err := s.ds.WithTxImmediate(func(tx model.DataStore) error {
|
|
var pls *model.Playlist
|
|
var err error
|
|
|
|
if playlistId != "" {
|
|
pls, err = tx.Playlist().Get(ctx, playlistId)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Ownership first: a non-owner must get ErrNotAuthorized, not a read-only conflict.
|
|
if !usr.IsAdmin && pls.OwnerID != usr.ID {
|
|
return model.ErrNotAuthorized
|
|
}
|
|
if !pls.TracksEditable() {
|
|
return model.ErrPlaylistNotEditable
|
|
}
|
|
} else {
|
|
pls = &model.Playlist{Name: name}
|
|
pls.OwnerID = usr.ID
|
|
}
|
|
pls.Tracks = nil
|
|
pls.AddMediaFilesByID(ids)
|
|
|
|
err = tx.Playlist().Put(ctx, pls)
|
|
playlistId = pls.ID
|
|
return err
|
|
})
|
|
return playlistId, err
|
|
}
|
|
|
|
func (s *playlists) Delete(ctx context.Context, id string) error {
|
|
pls, err := s.checkWritable(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Clean up custom cover image file if one exists
|
|
if path := pls.UploadedImagePath(); path != "" {
|
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
|
log.Warn(ctx, "Failed to remove playlist image on delete", "path", path, err)
|
|
}
|
|
}
|
|
|
|
return s.ds.Playlist().Delete(ctx, id)
|
|
}
|
|
|
|
func (s *playlists) Update(ctx context.Context, playlistID string,
|
|
name *string, comment *string, public *bool,
|
|
idsToAdd []string, idxToRemove []int) error {
|
|
var pls *model.Playlist
|
|
var err error
|
|
hasTrackChanges := len(idsToAdd) > 0 || len(idxToRemove) > 0
|
|
if hasTrackChanges {
|
|
pls, err = s.checkTracksEditable(ctx, playlistID)
|
|
} else {
|
|
pls, err = s.checkWritable(ctx, playlistID)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.ds.WithTxImmediate(func(tx model.DataStore) error {
|
|
repo := tx.Playlist()
|
|
|
|
if len(idxToRemove) > 0 {
|
|
tracksRepo := repo.Tracks(ctx, playlistID, false)
|
|
// Convert 0-based indices to 1-based position IDs and delete them directly,
|
|
// avoiding the need to load all tracks into memory.
|
|
positions := make([]string, len(idxToRemove))
|
|
for i, idx := range idxToRemove {
|
|
positions[i] = strconv.Itoa(idx + 1)
|
|
}
|
|
if err := tracksRepo.Delete(ctx, positions...); err != nil {
|
|
return err
|
|
}
|
|
if len(idsToAdd) > 0 {
|
|
if _, err := tracksRepo.Add(ctx, idsToAdd); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return s.updateMetadata(ctx, tx, pls, name, comment, public)
|
|
}
|
|
|
|
if len(idsToAdd) > 0 {
|
|
if _, err := repo.Tracks(ctx, playlistID, false).Add(ctx, idsToAdd); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if name == nil && comment == nil && public == nil {
|
|
return nil
|
|
}
|
|
// Reuse the playlist from checkWritable (no tracks loaded, so Put only refreshes counters)
|
|
return s.updateMetadata(ctx, tx, pls, name, comment, public)
|
|
})
|
|
}
|
|
|
|
// --- Permission helpers ---
|
|
|
|
// checkWritable fetches the playlist and verifies the current user can modify it.
|
|
func (s *playlists) checkWritable(ctx context.Context, id string) (*model.Playlist, error) {
|
|
pls, err := s.ds.Playlist().Get(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
usr, _ := request.UserFrom(ctx)
|
|
if !usr.IsAdmin && pls.OwnerID != usr.ID {
|
|
return nil, model.ErrNotAuthorized
|
|
}
|
|
return pls, nil
|
|
}
|
|
|
|
// checkTracksEditable verifies the user owns the playlist and its tracks are editable.
|
|
func (s *playlists) checkTracksEditable(ctx context.Context, playlistID string) (*model.Playlist, error) {
|
|
pls, err := s.checkWritable(ctx, playlistID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !pls.TracksEditable() {
|
|
return nil, model.ErrPlaylistNotEditable
|
|
}
|
|
return pls, nil
|
|
}
|
|
|
|
// updateMetadata applies optional metadata changes to a playlist and persists it.
|
|
// Accepts a DataStore parameter so it can be used inside transactions.
|
|
// The caller is responsible for permission checks.
|
|
func (s *playlists) updateMetadata(ctx context.Context, ds model.DataStore, pls *model.Playlist, name *string, comment *string, public *bool) error {
|
|
if name != nil {
|
|
pls.Name = *name
|
|
}
|
|
if comment != nil {
|
|
pls.Comment = *comment
|
|
}
|
|
if public != nil {
|
|
pls.Public = *public
|
|
}
|
|
return ds.Playlist().Put(ctx, pls)
|
|
}
|
|
|
|
// --- Track management operations ---
|
|
|
|
func (s *playlists) AddTracks(ctx context.Context, playlistID string, ids []string) (int, error) {
|
|
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
|
return 0, err
|
|
}
|
|
return s.ds.Playlist().Tracks(ctx, playlistID, false).Add(ctx, ids)
|
|
}
|
|
|
|
// InsertTracks adds tracks before the 1-based position pos; a position past the end appends.
|
|
func (s *playlists) InsertTracks(ctx context.Context, playlistID string, ids []string, pos int) (int, error) {
|
|
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
|
return 0, err
|
|
}
|
|
var count int
|
|
// Immediate: a deferred tx that reads before shifting fails at once with SQLITE_BUSY under
|
|
// concurrent writers instead of waiting for the lock.
|
|
err := s.ds.WithTxImmediate(func(tx model.DataStore) error {
|
|
var err error
|
|
count, err = tx.Playlist().Tracks(ctx, playlistID, false).Insert(ctx, ids, pos)
|
|
return err
|
|
})
|
|
return count, err
|
|
}
|
|
|
|
func (s *playlists) AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error) {
|
|
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
|
return 0, err
|
|
}
|
|
return s.ds.Playlist().Tracks(ctx, playlistID, false).AddAlbums(ctx, albumIds)
|
|
}
|
|
|
|
func (s *playlists) AddArtists(ctx context.Context, playlistID string, artistIds []string) (int, error) {
|
|
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
|
return 0, err
|
|
}
|
|
return s.ds.Playlist().Tracks(ctx, playlistID, false).AddArtists(ctx, artistIds)
|
|
}
|
|
|
|
func (s *playlists) AddDiscs(ctx context.Context, playlistID string, discs []model.DiscID) (int, error) {
|
|
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
|
return 0, err
|
|
}
|
|
return s.ds.Playlist().Tracks(ctx, playlistID, false).AddDiscs(ctx, discs)
|
|
}
|
|
|
|
func (s *playlists) RemoveTracks(ctx context.Context, playlistID string, trackIds []string) error {
|
|
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
|
return err
|
|
}
|
|
return s.ds.WithTx(func(tx model.DataStore) error {
|
|
return tx.Playlist().Tracks(ctx, playlistID, false).Delete(ctx, trackIds...)
|
|
})
|
|
}
|
|
|
|
func (s *playlists) ReorderTrack(ctx context.Context, playlistID string, pos int, newPos int) error {
|
|
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
|
return err
|
|
}
|
|
return s.ds.WithTxImmediate(func(tx model.DataStore) error {
|
|
return tx.Playlist().Tracks(ctx, playlistID, false).Reorder(ctx, pos, newPos)
|
|
})
|
|
}
|
|
|
|
// --- Cover art operations ---
|
|
|
|
func (s *playlists) SetImage(ctx context.Context, playlistID string, reader io.Reader, ext string) error {
|
|
pls, err := s.checkWritable(ctx, playlistID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
oldPath := pls.UploadedImagePath()
|
|
filename, err := s.imgUpload.SetImage(ctx, consts.EntityPlaylist, pls.ID, pls.Name, oldPath, reader, ext)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
pls.UploadedImage = filename
|
|
if err := s.ds.Playlist().Put(ctx, pls); err != nil {
|
|
return err
|
|
}
|
|
s.imgUpload.EnqueueArtwork(ctx, consts.EntityPlaylist, pls.ID)
|
|
return nil
|
|
}
|
|
|
|
func (s *playlists) RemoveImage(ctx context.Context, playlistID string) error {
|
|
pls, err := s.checkWritable(ctx, playlistID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := s.imgUpload.RemoveImage(ctx, pls.UploadedImagePath()); err != nil {
|
|
return err
|
|
}
|
|
|
|
pls.UploadedImage = ""
|
|
if err := s.ds.Playlist().Put(ctx, pls); err != nil {
|
|
return err
|
|
}
|
|
s.imgUpload.EnqueueArtwork(ctx, consts.EntityPlaylist, pls.ID)
|
|
return nil
|
|
}
|