Merge branch 'master' into album-image-gallery

This commit is contained in:
Deluan Quintão 2026-06-20 12:35:28 -04:00 committed by GitHub
commit dce4883654
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
217 changed files with 9174 additions and 1478 deletions

View File

@ -13,6 +13,7 @@ linters:
- dogsled
- durationcheck
- errorlint
- forbidigo
- gocritic
- gocyclo
- goprintffuncname
@ -36,6 +37,14 @@ linters:
- G401
- G505
- G115
forbidigo:
forbid:
- pattern: 'tx\.Exec$'
msg: "use tx.ExecContext(ctx, ...) in migrations to propagate context"
- pattern: 'tx\.Query$'
msg: "use tx.QueryContext(ctx, ...) in migrations to propagate context"
- pattern: 'tx\.QueryRow$'
msg: "use tx.QueryRowContext(ctx, ...) in migrations to propagate context"
govet:
enable:
- nilness
@ -45,6 +54,9 @@ linters:
- gosec
path: _test\.go
text: "G703"
- path-except: 'db/migrations/'
linters:
- forbidigo
generated: lax
presets:
- comments
@ -56,6 +68,7 @@ linters:
- builtin$
- examples$
- node_modules
- _gen\.go$
formatters:
exclusions:
generated: lax

View File

@ -69,8 +69,16 @@ RUN --mount=type=bind,source=. \
set -e
xx-go --wrap
export CGO_ENABLED=1
# Native libwebp (gen2brain/webp) uses ebitengine/purego reverse callbacks,
# which purego does not support on 32-bit ARM or x86 and crash with a SIGSEGV
# (issue #5597). Build those arches with the "nodynamic" tag so gen2brain/webp
# is WASM-only and never links the purego path. 64-bit arches keep native libwebp.
BUILD_TAGS=netgo,sqlite_fts5
if [ "$(xx-info arch)" = "arm" ] || [ "$(xx-info arch)" = "386" ]; then
BUILD_TAGS=${BUILD_TAGS},nodynamic
fi
# -latomic is required on 32-bit arm (arm/v6, arm/v7) so SQLite's 64-bit atomics resolve.
go build -tags=netgo,sqlite_fts5 -ldflags="-w -s \
go build -tags=${BUILD_TAGS} -ldflags="-w -s \
-linkmode=external -extldflags '-latomic' \
-X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \
-X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \
@ -159,7 +167,6 @@ ENV ND_MUSICFOLDER=/music
ENV ND_DATAFOLDER=/data
ENV ND_CONFIGFILE=/data/navidrome.toml
ENV ND_PORT=4533
ENV ND_ENABLEWEBPENCODING=true
RUN touch /.nddockerenv
EXPOSE ${ND_PORT}

View File

@ -52,6 +52,7 @@ A share of the revenue helps fund the development of Navidrome at no additional
- **Multi-platform**, runs on macOS, Linux and Windows. **Docker** images are also provided
- Ready to use binaries for all major platforms, including **Raspberry Pi**
- Automatically **monitors your library** for changes, importing new files and reloading new metadata
- Supports **lyrics** from sidecar .ttml, .yaml/.yml Lyricsfile, .elrc, .lrc, .srt, .txt files and embedded TTML, Enhanced LRC, LRC, SRT, and plain-text tags (via `lyricspriority`)
- **Themeable**, modern and responsive **Web interface** based on [Material UI](https://material-ui.com)
- **Compatible** with all Subsonic/Madsonic/Airsonic [clients](https://www.navidrome.org/docs/overview/#apps)
- **Transcoding** on the fly. Can be set per user/player. **Opus encoding is supported**

View File

@ -109,7 +109,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
playbackServer := playback.GetInstance(dataStore)
lyricsLyrics := lyrics.NewLyrics(manager)
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic)

View File

@ -162,6 +162,7 @@ type scannerOptions struct {
GenreSeparators string // Deprecated: Use Tags.genre.Split instead
GroupAlbumReleases bool // Deprecated: Use PID.Album instead
FollowSymlinks bool // Whether to follow symlinks when scanning directories
IgnoreDotFolders bool // Whether to ignore folders whose name starts with a dot when scanning
PurgeMissing string // Values: "never", "always", "full"
}
@ -776,7 +777,7 @@ func setViperDefaults() {
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
viper.SetDefault("artistimagefolder", "")
viper.SetDefault("discartpriority", "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded")
viper.SetDefault("lyricspriority", ".lrc,.txt,embedded")
viper.SetDefault("lyricspriority", ".ttml,.yaml,.yml,.elrc,.lrc,.srt,.txt,embedded")
viper.SetDefault("enablegravatar", false)
viper.SetDefault("enablefavourites", true)
viper.SetDefault("enablestarrating", true)
@ -821,6 +822,7 @@ func setViperDefaults() {
viper.SetDefault("scanner.genreseparators", "")
viper.SetDefault("scanner.groupalbumreleases", false)
viper.SetDefault("scanner.followsymlinks", true)
viper.SetDefault("scanner.ignoredotfolders", true)
viper.SetDefault("scanner.purgemissing", consts.PurgeMissingNever)
viper.SetDefault("subsonic.appendsubtitle", true)
viper.SetDefault("subsonic.appendalbumversion", true)

View File

@ -14,6 +14,9 @@ const (
DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on&synchronous=normal"
InitialSetupFlagKey = "InitialSetup"
FullScanAfterMigrationFlagKey = "FullScanAfterMigration"
// PlaylistsImportPendingFlagKey marks that playlist import was deferred because
// no admin user existed yet; the next scan with an admin imports them.
PlaylistsImportPendingFlagKey = "PlaylistsImportPending"
LastScanErrorKey = "LastScanError"
LastScanTypeKey = "LastScanType"
LastScanStartTimeKey = "LastScanStartTime"
@ -153,25 +156,25 @@ var (
Name: "mp3 audio",
TargetFormat: "mp3",
DefaultBitRate: 192,
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -",
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -f mp3 -",
},
{
Name: "opus audio",
TargetFormat: "opus",
DefaultBitRate: 128,
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
},
{
Name: "aac audio",
TargetFormat: "aac",
DefaultBitRate: 256,
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
},
{
Name: "flac audio",
TargetFormat: "flac",
DefaultBitRate: 0,
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -",
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -v 0 -c:a flac -f flac -",
},
}
)

View File

@ -357,6 +357,98 @@ var _ = Describe("Album artwork resolution", func() {
})
})
// Regression introduced in v0.62.0 (#5451 + #5457): the parent-folder
// fallback can pick up images from the ARTIST folder, serving the artist
// thumbnail as album art for any album without its own image files.
When("an album has no images and the artist folder has folder.jpg", func() {
// Artist/
// ├── folder.jpg ← artist thumbnail, must NOT become album art
// ├── Album A/
// │ └── 01 - Track.mp3 (no images)
// └── Album B/
// ├── 01 - Track.mp3
// └── cover.jpg
It("does not use the artist image as album art", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/folder.jpg": imageFile("artist-thumbnail"),
"Artist/Album A/01 - Track.mp3": trackFile(1, "Track A", map[string]any{"album": "Album A", "albumartist": "Artist"}),
"Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}),
"Artist/Album B/cover.jpg": imageFile("album-b"),
})
scan()
alA := albumByName("Album A")
_, err := readArtworkOrErr(alA.CoverArtID())
Expect(err).To(HaveOccurred(),
"Album A has no images of its own, so it must fall through to the placeholder "+
"instead of inheriting the artist folder's folder.jpg")
alB := albumByName("Album B")
Expect(readArtwork(alB.CoverArtID())).To(Equal(imageBytes("album-b")))
})
})
When("a single-disc album is spread across sibling folders under the artist folder", func() {
// Artist/
// ├── folder.jpg ← artist thumbnail, must NOT become album art
// ├── Album A/
// │ └── 01 - Track.mp3 (album: "Album A")
// ├── Album A bonus/
// │ └── 02 - Track.mp3 (album: "Album A" — same album, second folder)
// └── Album B/
// ├── 01 - Track.mp3
// └── cover.jpg
It("does not use the artist image as album art for the spread album", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/folder.jpg": imageFile("artist-thumbnail"),
"Artist/Album A/01 - Track.mp3": trackFile(1, "Track A1", map[string]any{"album": "Album A", "albumartist": "Artist"}),
"Artist/Album A bonus/02 - Track.mp3": trackFile(2, "Track A2", map[string]any{"album": "Album A", "albumartist": "Artist"}),
"Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}),
"Artist/Album B/cover.jpg": imageFile("album-b"),
})
scan()
alA := albumByName("Album A")
Expect(alA.FolderIDs).To(HaveLen(2),
"sanity check: scanner should treat the two sibling folders as one spread album")
_, err := readArtworkOrErr(alA.CoverArtID())
Expect(err).To(HaveOccurred(),
"the spread album has no images of its own, so it must fall through to the "+
"placeholder instead of inheriting the artist folder's folder.jpg")
})
})
When("a spread album has its own front.jpg but the artist folder has cover.jpg", func() {
// Artist/
// ├── cover.jpg ← artist image; matches cover.* (first pattern),
// │ must NOT shadow the album's own front.jpg
// ├── Album A/
// │ ├── 01 - Track.mp3 (album: "Album A")
// │ └── front.jpg ← should win
// ├── Album A bonus/
// │ └── 02 - Track.mp3 (album: "Album A")
// └── Album B/
// └── 01 - Track.mp3
It("prefers the album's own art over the artist image", func() {
conf.Server.CoverArtPriority = defaultCoverPriority
setLayout(fstest.MapFS{
"Artist/cover.jpg": imageFile("artist-image"),
"Artist/Album A/01 - Track.mp3": trackFile(1, "Track A1", map[string]any{"album": "Album A", "albumartist": "Artist"}),
"Artist/Album A/front.jpg": imageFile("album-a-front"),
"Artist/Album A bonus/02 - Track.mp3": trackFile(2, "Track A2", map[string]any{"album": "Album A", "albumartist": "Artist"}),
"Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}),
})
scan()
alA := albumByName("Album A")
Expect(alA.FolderIDs).To(HaveLen(2),
"sanity check: scanner should treat the two sibling folders as one spread album")
Expect(readArtwork(alA.CoverArtID())).To(Equal(imageBytes("album-a-front")))
})
})
When("embedded is first in CoverArtPriority but the track has no embedded art", func() {
// Artist/
// └── Album/

View File

@ -2,6 +2,7 @@ package artworke2e_test
import (
"context"
"fmt"
"path/filepath"
"testing"
@ -104,3 +105,16 @@ func firstAlbum() model.Album {
Expect(albums).To(HaveLen(1), "expected exactly one album, got %d", len(albums))
return albums[0]
}
func albumByName(name string) model.Album {
GinkgoHelper()
albums, err := ds.Album(ctx).GetAll(model.QueryOptions{})
Expect(err).ToNot(HaveOccurred())
for _, al := range albums {
if al.Name == name {
return al
}
}
Fail(fmt.Sprintf("album %q not found among %d albums", name, len(albums)))
return model.Album{}
}

View File

@ -141,28 +141,12 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo
return nil, nil, nil, err
}
folderIDSet := make(map[string]bool, len(folderIDs))
for _, id := range folderIDs {
folderIDSet[id] = true
parent, err := albumRootParent(ctx, ds, folders, folderIDs)
if err != nil {
return nil, nil, nil, err
}
// Check if all folders share a common parent that is not already included.
// This finds cover art in the album root folder (e.g., "Artist/Album/cover.jpg"
// when tracks are in disc subfolders like "Artist/Album/CD1/" and "Artist/Album/CD2/").
// For single-folder albums, the parent is only included when the folder has no
// images of its own (indicating a disc subfolder needing parent artwork).
if commonParentID := commonParentFolder(folders, folderIDSet); commonParentID != "" {
if len(folders) >= 2 || !anyFolderHasImages(folders) {
parentFolder, err := ds.Folder(ctx).Get(commonParentID)
if errors.Is(err, model.ErrNotFound) {
log.Warn(ctx, "Parent folder not found for album cover art lookup", "parentID", commonParentID)
} else if err != nil {
return nil, nil, nil, err
}
if parentFolder != nil && parentFolder.ParentID != "" {
folders = append(folders, *parentFolder)
}
}
if parent != nil {
folders = append(folders, *parent)
}
var paths []string
@ -187,6 +171,50 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo
return paths, imgFiles, &updatedAt, nil
}
// albumRootParent returns the common parent of the album's folders when it
// qualifies as the album's root folder (e.g. "Artist/Album" above disc
// subfolders), or nil when there is no such parent. This finds cover art in
// the album root folder when tracks live in disc subfolders, like
// "Artist/Album/cover.jpg" with tracks in "Artist/Album/CD1/" and
// "Artist/Album/CD2/". The parent must look like an album root, not an
// artist-level folder — it qualifies only when it holds no audio belonging to
// other albums — so artist images are never served as album art.
func albumRootParent(ctx context.Context, ds model.DataStore, folders []model.Folder, folderIDs []string) (*model.Folder, error) {
folderIDSet := make(map[string]bool, len(folderIDs))
for _, id := range folderIDs {
folderIDSet[id] = true
}
commonParentID := commonParentFolder(folders, folderIDSet)
if commonParentID == "" {
return nil, nil
}
// Single-folder albums only use the parent when the folder has no images
// of its own (indicating a disc subfolder needing parent artwork).
if len(folders) < 2 && anyFolderHasImages(folders) {
return nil, nil
}
parent, err := ds.Folder(ctx).Get(commonParentID)
if errors.Is(err, model.ErrNotFound) {
log.Warn(ctx, "Parent folder not found for album cover art lookup", "parentID", commonParentID)
return nil, nil
}
if err != nil {
return nil, err
}
if parent.ParentID == "" {
// The library root can never be an album root
return nil, nil
}
hasOtherAudio, err := ds.Folder(ctx).HasAudioOutsideFolders(*parent, folderIDs)
if err != nil {
return nil, err
}
if hasOtherAudio {
return nil, nil
}
return parent, nil
}
func anyFolderHasImages(folders []model.Folder) bool {
for _, f := range folders {
if len(f.ImageFiles) > 0 {

View File

@ -339,6 +339,61 @@ var _ = Describe("Album Artwork Reader", func() {
Expect(repo.getCallCount).To(Equal(1))
})
It("does not include parent images when other albums' audio lives under the parent", func() {
// Simulates: Artist/folder.jpg with Artist/Album (no images) and
// another album's tracks elsewhere under the artist folder
repo.result = []model.Folder{
{
ID: "folder1",
Path: "Artist",
Name: "Album",
ParentID: "artistFolder",
ImagesUpdatedAt: now,
ImageFiles: []string{},
},
}
repo.parentResult = &model.Folder{
ID: "artistFolder",
Path: ".",
Name: "Artist",
ParentID: "libraryRoot",
ImagesUpdatedAt: expectedAt,
ImageFiles: []string{"folder.jpg"},
}
repo.hasOtherAudio = true
_, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
Expect(err).ToNot(HaveOccurred())
Expect(imgFiles).To(BeEmpty())
})
It("propagates errors from the album-root check", func() {
repo.result = []model.Folder{
{
ID: "folder1",
Path: "Artist/Album",
Name: "disc1",
ParentID: "albumFolder",
ImagesUpdatedAt: now,
ImageFiles: []string{},
},
}
repo.parentResult = &model.Folder{
ID: "albumFolder",
Path: "Artist",
Name: "Album",
ParentID: "artistFolder",
ImagesUpdatedAt: expectedAt,
ImageFiles: []string{"cover.jpg"},
}
repo.otherAudioErr = errors.New("db connection failed")
_, _, _, err := loadAlbumFoldersPaths(ctx, ds, album)
Expect(err).To(MatchError("db connection failed"))
})
It("propagates non-ErrNotFound errors from parent folder lookup", func() {
repo.result = []model.Folder{
{

View File

@ -702,12 +702,20 @@ type fakeFolderRepo struct {
getErr error
getCallCount int
err error
// hasOtherAudio is returned by HasAudioOutsideFolders (the album-root
// check). False means the parent qualifies as an album root.
hasOtherAudio bool
otherAudioErr error
}
func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) {
return f.result, f.err
}
func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) {
return f.hasOtherAudio, f.otherAudioErr
}
func (f *fakeFolderRepo) Get(id string) (*model.Folder, error) {
f.getCallCount++
if f.getErr != nil {

View File

@ -21,6 +21,12 @@ import (
func init() {
conf.AddHook(func() {
// gen2brain/webp selects native (purego/libwebp) vs WASM in its own
// package init() and exposes the result only via webp.Dynamic(); there is
// no runtime way to switch back. On 32-bit ARM/x86 the purego callback path
// crashes (issue #5597), so those builds must be compiled with the
// "nodynamic" tag (see Dockerfile), which makes webp.Dynamic() report an
// error here and forces the safe WASM path.
if err := webp.Dynamic(); err != nil {
log.Debug("Using WASM WebP encoder/decoder", "reason", err)
} else {

View File

@ -403,6 +403,14 @@ func buildDynamicArgs(opts TranscodeOptions) []string {
args = append(args, "-i", opts.FilePath)
args = append(args, "-map", "0:a:0")
// Preserve source tags. -map_metadata 0 copies format-level tags (MP3/FLAC);
// -map_metadata 0:s:a:0 copies tags from the first audio stream (OPUS/OGG).
// Both are needed because the two source families store tags at different
// levels. Targeting the audio stream explicitly (s:a:0 rather than s:0) avoids
// pulling metadata from an embedded cover-art/video stream at index 0. Note:
// adts (AAC) output cannot hold tags, so these are a no-op there.
args = append(args, "-map_metadata", "0", "-map_metadata", "0:s:a:0")
if codec, ok := formatCodecMap[opts.Format]; ok {
args = append(args, "-c:a", codec)
}

View File

@ -82,16 +82,16 @@ var _ = Describe("ffmpeg", func() {
Describe("isDefaultCommand", func() {
It("returns true for known default mp3 command", func() {
Expect(isDefaultCommand("mp3", "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue())
Expect(isDefaultCommand("mp3", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue())
})
It("returns true for known default opus command", func() {
Expect(isDefaultCommand("opus", "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue())
Expect(isDefaultCommand("opus", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue())
})
It("returns true for known default aac command", func() {
Expect(isDefaultCommand("aac", "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -")).To(BeTrue())
Expect(isDefaultCommand("aac", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a aac -f adts -")).To(BeTrue())
})
It("returns true for known default flac command", func() {
Expect(isDefaultCommand("flac", "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue())
Expect(isDefaultCommand("flac", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue())
})
It("returns false for a custom command", func() {
Expect(isDefaultCommand("mp3", "ffmpeg -i %s -b:a %bk -custom-flag -f mp3 -")).To(BeFalse())
@ -113,6 +113,7 @@ var _ = Describe("ffmpeg", func() {
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.flac",
"-map", "0:a:0",
"-map_metadata", "0", "-map_metadata", "0:s:a:0",
"-c:a", "libmp3lame",
"-b:a", "256k",
"-ar", "48000",
@ -132,6 +133,7 @@ var _ = Describe("ffmpeg", func() {
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.dsf",
"-map", "0:a:0",
"-map_metadata", "0", "-map_metadata", "0:s:a:0",
"-c:a", "flac",
"-ar", "48000",
"-v", "0",
@ -149,6 +151,7 @@ var _ = Describe("ffmpeg", func() {
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.flac",
"-map", "0:a:0",
"-map_metadata", "0", "-map_metadata", "0:s:a:0",
"-c:a", "libopus",
"-b:a", "128k",
"-v", "0",
@ -169,6 +172,7 @@ var _ = Describe("ffmpeg", func() {
"-ss", "30",
"-i", "/music/file.mp3",
"-map", "0:a:0",
"-map_metadata", "0", "-map_metadata", "0:s:a:0",
"-c:a", "libmp3lame",
"-b:a", "192k",
"-v", "0",
@ -186,6 +190,7 @@ var _ = Describe("ffmpeg", func() {
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.flac",
"-map", "0:a:0",
"-map_metadata", "0", "-map_metadata", "0:s:a:0",
"-c:a", "aac",
"-b:a", "256k",
"-v", "0",
@ -203,6 +208,7 @@ var _ = Describe("ffmpeg", func() {
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.dsf",
"-map", "0:a:0",
"-map_metadata", "0", "-map_metadata", "0:s:a:0",
"-c:a", "flac",
"-sample_fmt", "s32",
"-v", "0",

View File

@ -253,7 +253,11 @@ func (r *libraryRepositoryWrapper) Delete(id string) error {
return r.mapError(err)
}
err = r.LibraryRepository.Delete(libID)
// Run the deletion in a transaction so the cascade delete and the orphaned-artist
// reconciliation it triggers (see libraryRepository.Delete) commit atomically.
err = r.ds.WithTx(func(tx model.DataStore) error {
return tx.Library(r.ctx).Delete(libID)
}, "delete library")
if err != nil {
return r.mapError(err)
}

View File

@ -4,56 +4,122 @@ import (
"context"
"strings"
. "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
)
// Lyrics can fetch lyrics for a media file.
type Lyrics interface {
// maxLegacyLyricsCandidates bounds the duplicate window scanned by the legacy
// artist/title lookup, so source-priority resolution can still reach older
// matches without turning it into an unbounded table scan.
const maxLegacyLyricsCandidates = 10
// Provider fetches lyrics for a single media file. It is the contract
// implemented by individual lyrics sources, such as plugins.
type Provider interface {
GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error)
}
// Lyrics resolves lyrics for media files, honoring the configured source
// priority.
type Lyrics interface {
Provider
GetLyricsByArtistTitle(ctx context.Context, artist, title string) (model.LyricList, error)
}
// PluginLoader discovers and loads lyrics provider plugins.
type PluginLoader interface {
LoadLyricsProvider(name string) (Lyrics, bool)
LoadLyricsProvider(name string) (Provider, bool)
}
type lyricsService struct {
ds model.DataStore
pluginLoader PluginLoader
}
// NewLyrics creates a new lyrics service. pluginLoader may be nil if no plugin
// system is available.
func NewLyrics(pluginLoader PluginLoader) Lyrics {
return &lyricsService{pluginLoader: pluginLoader}
func NewLyrics(ds model.DataStore, pluginLoader PluginLoader) Lyrics {
return &lyricsService{ds: ds, pluginLoader: pluginLoader}
}
// GetLyrics returns lyrics for the given media file, trying sources in the
// order specified by conf.Server.LyricsPriority.
func (l *lyricsService) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
var lyricsList model.LyricList
var err error
return l.getLyricsForCandidates(ctx, []*model.MediaFile{mf})
}
// GetLyricsByArtistTitle resolves lyrics for the legacy artist/title lookup,
// scanning a bounded window of duplicate matches so source priority still wins
// across them.
func (l *lyricsService) GetLyricsByArtistTitle(ctx context.Context, artist, title string) (model.LyricList, error) {
opts := songsByArtistTitleWithLyricsFirst(artist, title)
opts.Max = maxLegacyLyricsCandidates
mediaFiles, err := l.ds.MediaFile(ctx).GetAll(opts)
if err != nil {
return nil, err
}
if len(mediaFiles) == 0 {
return nil, nil
}
candidates := make([]*model.MediaFile, 0, len(mediaFiles))
for i := range mediaFiles {
candidates = append(candidates, &mediaFiles[i])
}
return l.getLyricsForCandidates(ctx, candidates)
}
func songsByArtistTitleWithLyricsFirst(artist, title string) model.QueryOptions {
return model.QueryOptions{
Sort: "lyrics, updated_at",
Order: "desc",
Filters: And{
Eq{"missing": false},
Eq{"title": title},
Or{
persistence.Exists("json_tree(participants, '$.albumartist')", Eq{"value": artist}),
persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artist}),
},
},
}
}
func (l *lyricsService) getLyricsForCandidates(ctx context.Context, mediaFiles []*model.MediaFile) (model.LyricList, error) {
for pattern := range strings.SplitSeq(conf.Server.LyricsPriority, ",") {
pattern = strings.TrimSpace(pattern)
switch {
case strings.EqualFold(pattern, "embedded"):
lyricsList, err = fromEmbedded(ctx, mf)
case strings.HasPrefix(pattern, "."):
lyricsList, err = fromExternalFile(ctx, mf, strings.ToLower(pattern))
default:
lyricsList, err = l.fromPlugin(ctx, mf, pattern)
if pattern == "" {
continue
}
if err != nil {
log.Error(ctx, "error getting lyrics", "source", pattern, err)
}
for _, mf := range mediaFiles {
if mf == nil {
continue
}
if len(lyricsList) > 0 {
return lyricsList, nil
lyricsList, err := l.getLyricsFromSource(ctx, mf, pattern)
if err != nil {
log.Error(ctx, "error getting lyrics", "source", pattern, err)
continue
}
if len(lyricsList) > 0 {
return lyricsList, nil
}
}
}
return nil, nil
}
func (l *lyricsService) getLyricsFromSource(ctx context.Context, mf *model.MediaFile, pattern string) (model.LyricList, error) {
switch {
case strings.EqualFold(pattern, "embedded"):
return fromEmbedded(ctx, mf)
case strings.HasPrefix(pattern, "."):
return fromExternalFile(ctx, mf, pattern)
default:
return l.fromPlugin(ctx, mf, pattern)
}
}

View File

@ -1,9 +1,13 @@
package lyrics_test
import (
"io/fs"
"testing"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/storage/local"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/metadata"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -15,3 +19,17 @@ func TestLyrics(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Lyrics Suite")
}
// core/storage/local calls log.Fatal if the default scanner extractor is unregistered
// when constructing any localStorage. Register a no-op so storage.For("file://...") works
// in tests without importing the real extractor.
var _ = BeforeSuite(func() {
local.RegisterExtractor(consts.DefaultScannerExtractor, func(fs.FS, string) local.Extractor {
return &noopExtractor{}
})
})
type noopExtractor struct{}
func (e *noopExtractor) Parse(_ ...string) (map[string]metadata.Info, error) { return nil, nil }
func (e *noopExtractor) Version() string { return "noop" }

View File

@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
@ -16,13 +17,14 @@ import (
. "github.com/onsi/gomega"
)
var _ = Describe("sources", func() {
var _ = Describe("Lyrics", func() {
var mf model.MediaFile
var ctx context.Context
const badLyrics = "This is a set of lyrics\nThat is not good"
unsynced, _ := model.ToLyrics("xxx", badLyrics)
embeddedLyrics := model.LyricList{*unsynced}
unsyncedList, _ := model.ParseLyrics(".lrc", "xxx", []byte(badLyrics))
unsynced, _ := unsyncedList.Main()
embeddedLyrics := model.LyricList{unsynced}
syncedLyrics := model.LyricList{
model.Lyrics{
@ -44,6 +46,71 @@ var _ = Describe("sources", func() {
},
}
elrcLyrics := model.LyricList{
model.Lyrics{
DisplayArtist: "ELRC Artist",
DisplayTitle: "ELRC Song",
Lang: "eng",
Line: []model.Line{
{
Start: new(int64(1000)),
End: new(int64(3000)),
Value: "Lead words",
Cue: []model.Cue{
{
Start: new(int64(1000)),
End: new(int64(1500)),
Value: "Lead ",
ByteStart: 0,
ByteEnd: 4,
},
{
Start: new(int64(1500)),
End: new(int64(3000)),
Value: "words",
ByteStart: 5,
ByteEnd: 9,
},
},
},
{
Start: new(int64(3000)),
Value: "Fallback line",
},
},
Synced: true,
},
}
ttmlLyrics := model.LyricList{
model.Lyrics{
Kind: "main",
Lang: "eng",
Line: []model.Line{
{
Start: new(int64(18800)),
Value: "We're no strangers to love",
},
{
Start: new(int64(22800)),
Value: "You know the rules and so do I",
},
},
Synced: true,
},
model.Lyrics{
Kind: "main",
Lang: "por",
Line: []model.Line{
{
Start: new(int64(18800)),
Value: "Nao somos estranhos ao amor",
},
},
Synced: true,
},
}
unsyncedLyrics := model.LyricList{
model.Lyrics{
Lang: "xxx",
@ -59,6 +126,25 @@ var _ = Describe("sources", func() {
},
}
srtLyrics := model.LyricList{
model.Lyrics{
Lang: "xxx",
Line: []model.Line{
{
Start: new(int64(18800)),
End: new(int64(22800)),
Value: "We're from subtitles",
},
{
Start: new(int64(22801)),
End: new(int64(26000)),
Value: "Another subtitle line",
},
},
Synced: true,
},
}
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
@ -68,19 +154,104 @@ var _ = Describe("sources", func() {
Lyrics: string(lyricsJson),
Path: "tests/fixtures/test.mp3",
}
ctx = context.Background()
ctx = GinkgoT().Context()
})
DescribeTable("Lyrics Priority", func(priority string, expected model.LyricList) {
conf.Server.LyricsPriority = priority
svc := lyrics.NewLyrics(nil)
svc := lyrics.NewLyrics(nil, nil)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(expected))
},
Entry("embedded > lrc > txt", "embedded,.lrc,.txt", embeddedLyrics),
Entry("lrc > embedded > txt", ".lrc,embedded,.txt", syncedLyrics),
Entry("txt > lrc > embedded", ".txt,.lrc,embedded", unsyncedLyrics))
Entry("elrc > lrc > embedded", ".elrc,.lrc,embedded", elrcLyrics),
Entry("srt > txt > embedded", ".srt,.txt,embedded", srtLyrics),
Entry("txt > lrc > embedded", ".txt,.lrc,embedded", unsyncedLyrics),
Entry("ttml > elrc > lrc > srt > embedded", ".ttml,.elrc,.lrc,.srt,embedded", ttmlLyrics))
It("resolves source priority across duplicate media files", func() {
conf.Server.LyricsPriority = ".ttml,embedded"
embeddedJSON, err := json.Marshal(embeddedLyrics)
Expect(err).To(BeNil())
repo := &tests.MockMediaFileRepo{}
repo.SetData(model.MediaFiles{
{
Lyrics: string(embeddedJSON),
Path: "tests/fixtures/01 Invisible (RED) Edit Version.mp3",
},
{
Lyrics: "[]",
Path: "tests/fixtures/test.mp3",
},
})
svc := lyrics.NewLyrics(&tests.MockDataStore{MockedMediaFile: repo}, nil)
list, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up")
Expect(err).To(BeNil())
Expect(list).To(Equal(ttmlLyrics))
})
It("preserves configured sidecar suffix casing on case-sensitive filesystems", func() {
dir, err := os.MkdirTemp("", "lyrics-case-*")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
Expect(os.RemoveAll(dir)).To(Succeed())
})
probe := filepath.Join(dir, "CASECHECK")
Expect(os.WriteFile(probe, []byte("probe"), 0600)).To(Succeed())
_, err = os.Stat(filepath.Join(dir, "casecheck"))
if err == nil {
Skip("filesystem is case-insensitive")
}
Expect(os.IsNotExist(err)).To(BeTrue())
conf.Server.LyricsPriority = ".LRC"
Expect(os.WriteFile(filepath.Join(dir, "song.LRC"), []byte("[00:01.00]Upper suffix"), 0600)).To(Succeed())
svc := lyrics.NewLyrics(nil, nil)
list, err := svc.GetLyrics(ctx, &model.MediaFile{
LibraryPath: dir,
Path: "song.mp3",
})
Expect(err).To(BeNil())
Expect(list).To(HaveLen(1))
Expect(list[0].Line).To(Equal([]model.Line{
{Start: new(int64(1000)), Value: "Upper suffix"},
}))
})
It("returns a non-Lyricsfile YAML sidecar as plain text, shadowing lower-priority sources", func() {
dir, err := os.MkdirTemp("", "lyrics-yaml-fallback-*")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
Expect(os.RemoveAll(dir)).To(Succeed())
})
Expect(os.WriteFile(filepath.Join(dir, "song.yaml"), []byte("title: not lyricsfile\n"), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(dir, "song.lrc"), []byte("[00:01.00]Fallback line"), 0600)).To(Succeed())
conf.Server.LyricsPriority = ".yaml,.lrc"
svc := lyrics.NewLyrics(nil, nil)
list, err := svc.GetLyrics(ctx, &model.MediaFile{
LibraryPath: dir,
Path: "song.mp3",
})
// ParseLyrics falls back to plain text for any suffix when the content
// doesn't match the structured format, so the .yaml hit is non-empty and
// shadows the lower-priority .lrc entirely.
Expect(err).To(BeNil())
Expect(list).To(HaveLen(1))
Expect(list[0].Synced).To(BeFalse())
Expect(list[0].Line).To(Equal([]model.Line{
{Value: "title: not lyricsfile"},
}))
})
Context("Errors", func() {
var RegularUserContext = XContext
@ -110,7 +281,7 @@ var _ = Describe("sources", func() {
It("should fallback to embedded if an error happens when parsing file", func() {
conf.Server.LyricsPriority = ".mp3,embedded"
svc := lyrics.NewLyrics(nil)
svc := lyrics.NewLyrics(nil, nil)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(embeddedLyrics))
@ -119,7 +290,7 @@ var _ = Describe("sources", func() {
It("should return nothing if error happens when trying to parse file", func() {
conf.Server.LyricsPriority = ".mp3"
svc := lyrics.NewLyrics(nil)
svc := lyrics.NewLyrics(nil, nil)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(BeEmpty())
@ -137,7 +308,7 @@ var _ = Describe("sources", func() {
It("should return lyrics from a plugin", func() {
conf.Server.LyricsPriority = "test-lyrics-plugin"
mockLoader.lyrics = unsyncedLyrics
svc := lyrics.NewLyrics(mockLoader)
svc := lyrics.NewLyrics(nil, mockLoader)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(unsyncedLyrics))
@ -147,7 +318,7 @@ var _ = Describe("sources", func() {
conf.Server.LyricsPriority = "embedded,test-lyrics-plugin"
mf.Lyrics = "" // No embedded lyrics
mockLoader.lyrics = unsyncedLyrics
svc := lyrics.NewLyrics(mockLoader)
svc := lyrics.NewLyrics(nil, mockLoader)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(unsyncedLyrics))
@ -156,7 +327,7 @@ var _ = Describe("sources", func() {
It("should skip plugin if embedded has lyrics", func() {
conf.Server.LyricsPriority = "embedded,test-lyrics-plugin"
mockLoader.lyrics = unsyncedLyrics
svc := lyrics.NewLyrics(mockLoader)
svc := lyrics.NewLyrics(nil, mockLoader)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(embeddedLyrics)) // embedded wins
@ -165,7 +336,7 @@ var _ = Describe("sources", func() {
It("should skip unknown plugin names gracefully", func() {
conf.Server.LyricsPriority = "nonexistent-plugin,embedded"
mockLoader.notFound = true
svc := lyrics.NewLyrics(mockLoader)
svc := lyrics.NewLyrics(nil, mockLoader)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded
@ -175,7 +346,7 @@ var _ = Describe("sources", func() {
conf.Server.LyricsPriority = "MyLyricsPlugin"
mockLoader.pluginName = "MyLyricsPlugin"
mockLoader.lyrics = unsyncedLyrics
svc := lyrics.NewLyrics(mockLoader)
svc := lyrics.NewLyrics(nil, mockLoader)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(unsyncedLyrics))
@ -184,12 +355,56 @@ var _ = Describe("sources", func() {
It("should handle plugin error gracefully", func() {
conf.Server.LyricsPriority = "test-lyrics-plugin,embedded"
mockLoader.err = fmt.Errorf("plugin error")
svc := lyrics.NewLyrics(mockLoader)
svc := lyrics.NewLyrics(nil, mockLoader)
list, err := svc.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded
})
})
var _ = Describe("GetLyricsByArtistTitle", func() {
var svc lyrics.Lyrics
var repo *tests.MockMediaFileRepo
var ds *tests.MockDataStore
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.LyricsPriority = "embedded"
repo = &tests.MockMediaFileRepo{}
ds = &tests.MockDataStore{MockedMediaFile: repo}
svc = lyrics.NewLyrics(ds, nil)
})
It("bounds the query to a duplicate window", func() {
repo.SetData(model.MediaFiles{})
_, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up")
Expect(err).ToNot(HaveOccurred())
Expect(repo.Options.Max).To(Equal(10))
})
It("returns nil when no media file matches", func() {
repo.SetData(model.MediaFiles{})
list, err := svc.GetLyricsByArtistTitle(ctx, "Nobody", "No Song")
Expect(err).ToNot(HaveOccurred())
Expect(list).To(BeNil())
})
It("resolves lyrics from the matched media files", func() {
embeddedList, err := model.ParseLyrics(".lrc", "eng", []byte("Embedded lyrics line"))
Expect(err).ToNot(HaveOccurred())
embedded, _ := embeddedList.Main()
embeddedJSON, err := json.Marshal(model.LyricList{embedded})
Expect(err).ToNot(HaveOccurred())
repo.SetData(model.MediaFiles{
{ID: "1", Title: "Never Gonna Give You Up", Lyrics: string(embeddedJSON)},
})
list, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up")
Expect(err).ToNot(HaveOccurred())
Expect(list).To(HaveLen(1))
Expect(list[0].Line[0].Value).To(Equal("Embedded lyrics line"))
})
})
})
type mockPluginLoader struct {
@ -206,7 +421,7 @@ func (m *mockPluginLoader) PluginNames(_ string) []string {
return []string{"test-lyrics-plugin"}
}
func (m *mockPluginLoader) LoadLyricsProvider(name string) (lyrics.Lyrics, bool) {
func (m *mockPluginLoader) LoadLyricsProvider(name string) (lyrics.Provider, bool) {
if m.notFound {
return nil, false
}

View File

@ -3,9 +3,12 @@ package lyrics
import (
"context"
"errors"
"os"
"fmt"
"io"
"io/fs"
"path"
"github.com/navidrome/navidrome/core/storage"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/ioutils"
@ -23,31 +26,45 @@ func fromEmbedded(ctx context.Context, mf *model.MediaFile) (model.LyricList, er
}
func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (model.LyricList, error) {
basePath := mf.AbsolutePath()
ext := path.Ext(basePath)
ext := path.Ext(mf.Path)
sidecarRelPath := mf.Path[0:len(mf.Path)-len(ext)] + suffix
externalLyric := basePath[0:len(basePath)-len(ext)] + suffix
store, err := storage.For(mf.LibraryPath)
if err != nil {
return nil, fmt.Errorf("getting storage for library: %w", err)
}
fsys, err := store.FS()
if err != nil {
return nil, fmt.Errorf("opening library filesystem: %w", err)
}
contents, err := ioutils.UTF8ReadFile(externalLyric)
if errors.Is(err, os.ErrNotExist) {
log.Trace(ctx, "no lyrics found at path", "path", externalLyric)
f, err := fsys.Open(sidecarRelPath)
if errors.Is(err, fs.ErrNotExist) {
log.Trace(ctx, "no lyrics found at path", "path", sidecarRelPath)
return nil, nil
} else if err != nil {
return nil, err
}
defer f.Close()
lyrics, err := model.ToLyrics("xxx", string(contents))
contents, err := io.ReadAll(ioutils.UTF8Reader(f))
if err != nil {
log.Error(ctx, "error parsing lyric external file", "path", externalLyric, err)
return nil, err
} else if lyrics == nil {
log.Trace(ctx, "empty lyrics from external file", "path", externalLyric)
}
list, err := model.ParseLyrics(suffix, "xxx", contents)
if err != nil {
log.Error(ctx, "error parsing external lyric file", "path", sidecarRelPath, err)
return nil, err
}
if len(list) == 0 {
log.Trace(ctx, "empty lyrics from external file", "path", sidecarRelPath)
return nil, nil
}
log.Trace(ctx, "retrieved lyrics from external file", "path", externalLyric)
return model.LyricList{*lyrics}, nil
log.Trace(ctx, "retrieved lyrics from external file", "path", sidecarRelPath)
return list, nil
}
// fromPlugin attempts to load lyrics from a plugin with the given name.

View File

@ -3,6 +3,7 @@ package lyrics
import (
"context"
"encoding/json"
"path/filepath"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
@ -25,10 +26,12 @@ var _ = Describe("sources", func() {
const syncedLyrics = "[00:18.80]We're no strangers to love\n[00:22.801]You know the rules and so do I"
const unsyncedLyrics = "We're no strangers to love\nYou know the rules and so do I"
synced, _ := model.ToLyrics("eng", syncedLyrics)
unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics)
syncedList, _ := model.ParseLyrics(".lrc", "eng", []byte(syncedLyrics))
unsyncedList, _ := model.ParseLyrics(".lrc", "xxx", []byte(unsyncedLyrics))
synced, _ := syncedList.Main()
unsynced, _ := unsyncedList.Main()
expectedList := model.LyricList{*synced, *unsynced}
expectedList := model.LyricList{synced, unsynced}
lyricsJson, err := json.Marshal(expectedList)
Expect(err).ToNot(HaveOccurred())
@ -53,72 +56,51 @@ var _ = Describe("sources", func() {
})
Describe("fromExternalFile", func() {
var fixturesDir string
BeforeEach(func() {
// tests.Init sets CWD to the repo root, so "tests/fixtures" resolves correctly.
abs, err := filepath.Abs("tests/fixtures")
Expect(err).ToNot(HaveOccurred())
fixturesDir = abs
})
mf := func(name string) *model.MediaFile {
return &model.MediaFile{LibraryPath: fixturesDir, Path: name}
}
It("should return nil for lyrics that don't exist", func() {
mf := model.MediaFile{Path: "tests/fixtures/01 Invisible (RED) Edit Version.mp3"}
lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
lyrics, err := fromExternalFile(ctx, mf("01 Invisible (RED) Edit Version.mp3"), ".lrc")
Expect(err).To(BeNil())
Expect(lyrics).To(HaveLen(0))
})
It("should return synchronized lyrics from a file", func() {
mf := model.MediaFile{Path: "tests/fixtures/test.mp3"}
lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
// fromExternalFile delegates format parsing to model.ParseLyrics; the
// per-format parser output is covered exhaustively in the model package.
// Here we only verify each suffix is read from the library FS and routed.
DescribeTable("should read the sidecar file and route its suffix to a parser",
func(name, suffix string, expectSynced bool) {
lyrics, err := fromExternalFile(ctx, mf(name), suffix)
Expect(err).To(BeNil())
Expect(lyrics).To(Equal(model.LyricList{
model.Lyrics{
DisplayArtist: "Rick Astley",
DisplayTitle: "That one song",
Lang: "eng",
Line: []model.Line{
{
Start: new(int64(18800)),
Value: "We're no strangers to love",
},
{
Start: new(int64(22801)),
Value: "You know the rules and so do I",
},
},
Offset: new(int64(-100)),
Synced: true,
},
}))
})
It("should return unsynchronized lyrics from a file", func() {
mf := model.MediaFile{Path: "tests/fixtures/test.mp3"}
lyrics, err := fromExternalFile(ctx, &mf, ".txt")
Expect(err).To(BeNil())
Expect(lyrics).To(Equal(model.LyricList{
model.Lyrics{
Lang: "xxx",
Line: []model.Line{
{
Value: "We're no strangers to love",
},
{
Value: "You know the rules and so do I",
},
},
Synced: false,
},
}))
})
Expect(err).To(BeNil())
Expect(lyrics).ToNot(BeEmpty())
Expect(lyrics[0].Line).ToNot(BeEmpty())
Expect(lyrics[0].Synced).To(Equal(expectSynced))
},
Entry(".lrc synced", "test.mp3", ".lrc", true),
Entry(".elrc enhanced", "test.mp3", ".elrc", true),
Entry(".txt plain", "test.mp3", ".txt", false),
Entry(".srt subtitles", "test.mp3", ".srt", true),
Entry(".ttml multilingual", "test.mp3", ".ttml", true),
Entry(".yaml lyricsfile", "test.mp3", ".yaml", true),
)
It("should handle LRC files with UTF-8 BOM marker (issue #4631)", func() {
// The function looks for <basePath-without-ext><suffix>, so we need to pass
// a MediaFile with .mp3 path and look for .lrc suffix
mf := model.MediaFile{Path: "tests/fixtures/bom-test.mp3"}
lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
lyrics, err := fromExternalFile(ctx, mf("bom-test.mp3"), ".lrc")
Expect(err).To(BeNil())
Expect(lyrics).ToNot(BeNil())
Expect(lyrics).To(HaveLen(1))
// The critical assertion: even with BOM, synced should be true
Expect(lyrics[0].Synced).To(BeTrue(), "Lyrics with BOM marker should be recognized as synced")
Expect(lyrics[0].Line).To(HaveLen(1))
Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(0))))
@ -126,14 +108,10 @@ var _ = Describe("sources", func() {
})
It("should handle UTF-16 LE encoded LRC files", func() {
mf := model.MediaFile{Path: "tests/fixtures/bom-utf16-test.mp3"}
lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
lyrics, err := fromExternalFile(ctx, mf("bom-utf16-test.mp3"), ".lrc")
Expect(err).To(BeNil())
Expect(lyrics).ToNot(BeNil())
Expect(lyrics).To(HaveLen(1))
// UTF-16 should be properly converted to UTF-8
Expect(lyrics[0].Synced).To(BeTrue(), "UTF-16 encoded lyrics should be recognized as synced")
Expect(lyrics[0].Line).To(HaveLen(2))
Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(18800))))
@ -141,5 +119,31 @@ var _ = Describe("sources", func() {
Expect(lyrics[0].Line[1].Start).To(Equal(new(int64(22801))))
Expect(lyrics[0].Line[1].Value).To(Equal("You know the rules and so do I"))
})
It("should handle TTML files with UTF-8 BOM marker", func() {
lyrics, err := fromExternalFile(ctx, mf("bom-test.mp3"), ".ttml")
Expect(err).To(BeNil())
Expect(lyrics).To(HaveLen(1))
Expect(lyrics[0].Kind).To(Equal("main"))
Expect(lyrics[0].Synced).To(BeTrue())
Expect(lyrics[0].Line).To(HaveLen(1))
Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(0))))
Expect(lyrics[0].Line[0].Value).To(Equal("BOM test line"))
})
It("should handle UTF-16 BE encoded TTML files", func() {
lyrics, err := fromExternalFile(ctx, mf("bom-utf16-test.mp3"), ".ttml")
Expect(err).To(BeNil())
Expect(lyrics).To(HaveLen(1))
Expect(lyrics[0].Kind).To(Equal("main"))
Expect(lyrics[0].Synced).To(BeTrue())
Expect(lyrics[0].Line).To(HaveLen(2))
Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(18800))))
Expect(lyrics[0].Line[0].Value).To(Equal("UTF16 line one"))
Expect(lyrics[0].Line[1].Start).To(Equal(new(int64(22801))))
Expect(lyrics[0].Line[1].Value).To(Equal("UTF16 line two"))
})
})
})

View File

@ -12,6 +12,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/gg"
)
const fallbackBitrate = 256 // kbps
@ -142,7 +143,7 @@ func buildSourceStream(mf *model.MediaFile, probe *ffmpeg.AudioProbeResult) Deta
sd.Codec = mf.AudioCodec()
sd.Bitrate = mf.BitRate
sd.SampleRate = mf.SampleRate
sd.BitDepth = mf.BitDepth
sd.BitDepth = gg.V(mf.BitDepth)
sd.Channels = mf.Channels
}
sd.IsLossless = isLosslessFormat(sd.Codec)

View File

@ -12,6 +12,7 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -23,7 +24,7 @@ func withProbe(mf *model.MediaFile) *model.MediaFile {
Codec: mf.AudioCodec(),
BitRate: mf.BitRate,
SampleRate: mf.SampleRate,
BitDepth: mf.BitDepth,
BitDepth: gg.V(mf.BitDepth),
Channels: mf.Channels,
}
data, _ := json.Marshal(probe)
@ -243,7 +244,7 @@ var _ = Describe("Decider", func() {
Context("Transcoding", func() {
It("selects transcoding when direct play isn't possible", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 256, // kbps
DirectPlayProfiles: []DirectPlayProfile{
@ -278,7 +279,7 @@ var _ = Describe("Decider", func() {
})
It("uses default bitrate when client doesn't specify", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, BitDepth: new(16)})
ci := &ClientInfo{
TranscodingProfiles: []Profile{
{Container: "mp3", Protocol: ProtocolHTTP},
@ -331,7 +332,7 @@ var _ = Describe("Decider", func() {
})
It("selects first valid transcoding profile in order", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: new(16)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
DirectPlayProfiles: []DirectPlayProfile{
@ -351,7 +352,7 @@ var _ = Describe("Decider", func() {
Context("Lossless to lossless transcoding", func() {
It("allows lossless to lossless when samplerate needs downsampling", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 176400, BitDepth: 1})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 176400, BitDepth: new(1)})
ci := &ClientInfo{
MaxAudioBitrate: 1000,
DirectPlayProfiles: []DirectPlayProfile{
@ -369,7 +370,7 @@ var _ = Describe("Decider", func() {
It("sets IsLossless=true on transcoded stream when target is lossless", func() {
// Transcoding to mp3 (lossy) should result in IsLossless=false.
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
@ -526,7 +527,7 @@ var _ = Describe("Decider", func() {
})
It("rejects direct play due to samplerate limitation", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
@ -573,7 +574,7 @@ var _ = Describe("Decider", func() {
})
It("applies channel limitation to transcoded stream", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 48000, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 48000, BitDepth: new(16)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
@ -596,7 +597,7 @@ var _ = Describe("Decider", func() {
})
It("applies samplerate limitation to transcoded stream", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
@ -619,7 +620,7 @@ var _ = Describe("Decider", func() {
})
It("applies bitdepth limitation to transcoded stream", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
ci := &ClientInfo{
TranscodingProfiles: []Profile{
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
@ -642,7 +643,7 @@ var _ = Describe("Decider", func() {
})
It("preserves source bit depth when no limitation applies", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 24})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(24)})
ci := &ClientInfo{
TranscodingProfiles: []Profile{
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
@ -656,7 +657,7 @@ var _ = Describe("Decider", func() {
})
It("rejects transcoding profile when GreaterThanEqual cannot be satisfied", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
@ -680,7 +681,7 @@ var _ = Describe("Decider", func() {
Context("DSD sample rate conversion", func() {
It("converts DSD sample rate to PCM-equivalent in decision", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
@ -700,7 +701,7 @@ var _ = Describe("Decider", func() {
})
It("converts DSD sample rate for FLAC target without codec limit", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)})
ci := &ClientInfo{
TranscodingProfiles: []Profile{
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
@ -719,7 +720,7 @@ var _ = Describe("Decider", func() {
})
It("applies codec profile limit to DSD-converted FLAC sample rate", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)})
ci := &ClientInfo{
TranscodingProfiles: []Profile{
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
@ -746,7 +747,7 @@ var _ = Describe("Decider", func() {
})
It("applies audioBitdepth limitation to DSD-converted bit depth", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)})
ci := &ClientInfo{
TranscodingProfiles: []Profile{
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
@ -775,7 +776,7 @@ var _ = Describe("Decider", func() {
// Regression test for #5336: ffmpeg's mp3 encoder rejects >2 channels.
// The decider must clamp to the codec's hard limit even when no
// transcoding profile MaxAudioChannels is configured.
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
@ -791,7 +792,7 @@ var _ = Describe("Decider", func() {
})
It("honors a stricter profile MaxAudioChannels over the codec clamp", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
@ -806,7 +807,7 @@ var _ = Describe("Decider", func() {
})
It("applies the codec clamp when the profile limit is looser", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
@ -821,7 +822,7 @@ var _ = Describe("Decider", func() {
})
It("passes channels through unchanged for codecs with no hard limit", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
@ -840,7 +841,7 @@ var _ = Describe("Decider", func() {
Context("Probe-based lossless detection", func() {
It("uses probe codec name for lossless detection", func() {
// WavPack files: ffprobe reports codec as "wavpack", suffix is ".wv"
mf := &model.MediaFile{ID: "1", Suffix: "wv", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}
mf := &model.MediaFile{ID: "1", Suffix: "wv", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}
probe := ffmpeg.AudioProbeResult{
Codec: "wavpack", BitRate: 1000, SampleRate: 44100, BitDepth: 16, Channels: 2,
}
@ -884,7 +885,7 @@ var _ = Describe("Decider", func() {
Context("Opus fixed sample rate", func() {
It("sets Opus output to 48000Hz regardless of input", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 128,
TranscodingProfiles: []Profile{
@ -901,7 +902,7 @@ var _ = Describe("Decider", func() {
})
It("sets Opus output to 48000Hz even for 96kHz input", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: 24})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 128,
TranscodingProfiles: []Profile{
@ -917,7 +918,7 @@ var _ = Describe("Decider", func() {
Context("Container vs format separation", func() {
It("preserves mp4 container when falling back to aac format", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 256,
TranscodingProfiles: []Profile{
@ -935,7 +936,7 @@ var _ = Describe("Decider", func() {
})
It("uses container as format when container matches transcoding config", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 256,
TranscodingProfiles: []Profile{
@ -952,7 +953,7 @@ var _ = Describe("Decider", func() {
Context("MP3 max sample rate", func() {
It("caps sample rate at 48000 for MP3", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: 24})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: new(24)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
@ -966,7 +967,7 @@ var _ = Describe("Decider", func() {
})
It("preserves sample rate at 44100 for MP3", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
@ -982,7 +983,7 @@ var _ = Describe("Decider", func() {
Context("AAC max sample rate", func() {
It("caps sample rate at 96000 for AAC", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)})
ci := &ClientInfo{
MaxTranscodingAudioBitrate: 320,
TranscodingProfiles: []Profile{
@ -1025,7 +1026,7 @@ var _ = Describe("Decider", func() {
Context("Source stream details", func() {
It("populates source stream correctly with kbps bitrate", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24, Duration: 300.5, Size: 50000000})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24), Duration: 300.5, Size: 50000000})
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{
{Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}},
@ -1058,7 +1059,7 @@ var _ = Describe("Decider", func() {
})
It("ignores player MaxBitRate in context", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
Name: "TestClient",
DirectPlayProfiles: []DirectPlayProfile{
@ -1074,7 +1075,7 @@ var _ = Describe("Decider", func() {
Context("Format-aware default bitrate", func() {
It("uses opus default bitrate from DB", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: new(16)})
ci := &ClientInfo{
TranscodingProfiles: []Profile{
{Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP},
@ -1087,7 +1088,7 @@ var _ = Describe("Decider", func() {
})
It("uses aac default bitrate from DB", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
ci := &ClientInfo{
TranscodingProfiles: []Profile{
{Container: "aac", AudioCodec: "aac", Protocol: ProtocolHTTP},

View File

@ -12,7 +12,7 @@ import (
// buildLegacyClientInfo translates legacy Subsonic stream/download parameters
// into a ClientInfo for use with MakeDecision.
func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int) *ClientInfo {
func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int, playerMaxBitRate int) *ClientInfo {
ci := &ClientInfo{Name: "legacy"}
// Determine target format for transcoding
@ -22,6 +22,10 @@ func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int
targetFormat = reqFormat
case reqBitRate > 0 && reqBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "":
targetFormat = conf.Server.DefaultDownsamplingFormat
case playerMaxBitRate > 0 && playerMaxBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "":
// Server-side player MaxBitRate alone forces downsampling, even when the
// client sent no format/bitrate params (issue #5583, legacy /stream path).
targetFormat = conf.Server.DefaultDownsamplingFormat
}
if targetFormat != "" {
@ -63,15 +67,19 @@ func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile
return req
}
clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate)
playerMaxBitRate := 0
if player, ok := request.PlayerFrom(ctx); ok {
playerMaxBitRate = player.MaxBitRate
}
clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate, playerMaxBitRate)
// Apply server-side player transcoding override before making the decision
if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" {
clientInfo = applyServerOverride(ctx, clientInfo, &trc)
} else if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 {
if clientInfo.MaxAudioBitrate == 0 || player.MaxBitRate < clientInfo.MaxAudioBitrate {
modified := *clientInfo
modified.MaxAudioBitrate = player.MaxBitRate
} else if player, ok := request.PlayerFrom(ctx); ok {
modified := *clientInfo
if modified.CapBitrate(player.MaxBitRate) {
clientInfo = &modified
log.Debug(ctx, "Applied player MaxBitRate cap", "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name)
}

View File

@ -21,7 +21,7 @@ var _ = Describe("buildLegacyClientInfo", func() {
})
It("sets transcoding profile for explicit format without bitrate", func() {
ci := buildLegacyClientInfo(mf, "mp3", 0)
ci := buildLegacyClientInfo(mf, "mp3", 0, 0)
Expect(ci.Name).To(Equal("legacy"))
Expect(ci.TranscodingProfiles).To(HaveLen(1))
@ -34,7 +34,7 @@ var _ = Describe("buildLegacyClientInfo", func() {
})
It("does not add direct play profile when explicit format differs from source (no bitrate)", func() {
ci := buildLegacyClientInfo(mf, "opus", 0)
ci := buildLegacyClientInfo(mf, "opus", 0, 0)
Expect(ci.TranscodingProfiles).To(HaveLen(1))
Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus"))
@ -42,7 +42,7 @@ var _ = Describe("buildLegacyClientInfo", func() {
})
It("adds direct play profile when explicit format matches source format", func() {
ci := buildLegacyClientInfo(mf, "flac", 0)
ci := buildLegacyClientInfo(mf, "flac", 0, 0)
Expect(ci.TranscodingProfiles).To(HaveLen(1))
Expect(ci.TranscodingProfiles[0].Container).To(Equal("flac"))
@ -52,7 +52,7 @@ var _ = Describe("buildLegacyClientInfo", func() {
})
It("sets transcoding profile and bitrate for explicit format with bitrate", func() {
ci := buildLegacyClientInfo(mf, "mp3", 192)
ci := buildLegacyClientInfo(mf, "mp3", 192, 0)
Expect(ci.TranscodingProfiles).To(HaveLen(1))
Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3"))
@ -63,7 +63,7 @@ var _ = Describe("buildLegacyClientInfo", func() {
})
It("returns direct play profile when no format and no bitrate", func() {
ci := buildLegacyClientInfo(mf, "", 0)
ci := buildLegacyClientInfo(mf, "", 0, 0)
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty())
@ -77,7 +77,7 @@ var _ = Describe("buildLegacyClientInfo", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultDownsamplingFormat = "opus"
ci := buildLegacyClientInfo(mf, "", 128)
ci := buildLegacyClientInfo(mf, "", 128, 0)
Expect(ci.TranscodingProfiles).To(HaveLen(1))
Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus"))
@ -91,7 +91,7 @@ var _ = Describe("buildLegacyClientInfo", func() {
})
It("returns direct play when bitrate >= source bitrate", func() {
ci := buildLegacyClientInfo(mf, "", 960)
ci := buildLegacyClientInfo(mf, "", 960, 0)
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty())
@ -100,6 +100,51 @@ var _ = Describe("buildLegacyClientInfo", func() {
Expect(ci.TranscodingProfiles).To(BeEmpty())
Expect(ci.MaxAudioBitrate).To(BeZero())
})
It("uses default downsampling format when player MaxBitRate is below source and no format/bitrate", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultDownsamplingFormat = "opus"
ci := buildLegacyClientInfo(mf, "", 0, 256)
Expect(ci.TranscodingProfiles).To(HaveLen(1))
Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus"))
Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus"))
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
Expect(ci.DirectPlayProfiles[0].Containers).To(Equal([]string{"flac"}))
})
It("does not downsample when player MaxBitRate is >= source bitrate", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultDownsamplingFormat = "opus"
ci := buildLegacyClientInfo(mf, "", 0, 960)
Expect(ci.TranscodingProfiles).To(BeEmpty())
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty())
})
It("does not downsample when DefaultDownsamplingFormat is empty even with player cap", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultDownsamplingFormat = ""
ci := buildLegacyClientInfo(mf, "", 0, 256)
Expect(ci.TranscodingProfiles).To(BeEmpty())
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty())
})
It("prefers explicit request format over player cap", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultDownsamplingFormat = "opus"
ci := buildLegacyClientInfo(mf, "mp3", 0, 256)
Expect(ci.TranscodingProfiles).To(HaveLen(1))
Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3"))
})
})
var _ = Describe("ResolveRequest", func() {
@ -138,7 +183,7 @@ var _ = Describe("ResolveRequest", func() {
})
It("transcodes to requested format", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
decider := svc.(*deciderService)
req := decider.ResolveRequest(ctx, mf, "opus", 0, 0)
@ -147,7 +192,7 @@ var _ = Describe("ResolveRequest", func() {
})
It("transcodes to requested format with bitrate limit", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
decider := svc.(*deciderService)
req := decider.ResolveRequest(ctx, mf, "mp3", 128, 0)
@ -169,7 +214,7 @@ var _ = Describe("ResolveRequest", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultDownsamplingFormat = "opus"
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
decider := svc.(*deciderService)
req := decider.ResolveRequest(ctx, mf, "", 128, 0)
@ -179,7 +224,7 @@ var _ = Describe("ResolveRequest", func() {
})
It("passes offset through", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
decider := svc.(*deciderService)
req := decider.ResolveRequest(ctx, mf, "opus", 128, 30)
@ -259,7 +304,7 @@ var _ = Describe("ResolveRequest", func() {
Context("Player MaxBitRate cap", func() {
It("applies player MaxBitRate cap when client has no limit", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 320})
decider := svc.(*deciderService)
@ -270,7 +315,7 @@ var _ = Describe("ResolveRequest", func() {
})
It("uses client limit when it is more restrictive than player MaxBitRate", func() {
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 500})
decider := svc.(*deciderService)
@ -289,6 +334,33 @@ var _ = Describe("ResolveRequest", func() {
Expect(req.Format).To(Equal("raw"))
})
It("downsamples using DefaultDownsamplingFormat when only player MaxBitRate is set (no format/bitrate)", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultDownsamplingFormat = "opus"
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 256})
decider := svc.(*deciderService)
req := decider.ResolveRequest(playerCtx, mf, "", 0, 0)
Expect(req.Format).To(Equal("opus"))
Expect(req.BitRate).To(Equal(256))
})
It("serves raw when only player MaxBitRate is set but no DefaultDownsamplingFormat", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultDownsamplingFormat = ""
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 256})
decider := svc.(*deciderService)
req := decider.ResolveRequest(playerCtx, mf, "", 0, 0)
Expect(req.Format).To(Equal("raw"))
})
})
Context("fallback for unknown format", func() {
@ -332,7 +404,7 @@ var _ = Describe("ResolveRequest", func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.DefaultDownsamplingFormat = "opus"
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16})
mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)})
decider := svc.(*deciderService)
req := decider.ResolveRequest(ctx, mf, "xyz", 128, 0)

View File

@ -40,6 +40,50 @@ type ClientInfo struct {
CodecProfiles []CodecProfile
}
// CapBitrate lowers the client's declared audio bitrate limits to maxKbps,
// never raising them. A zero limit means "unlimited" and is set to maxKbps.
// Returns true if anything changed. No-op when maxKbps <= 0.
func (ci *ClientInfo) CapBitrate(maxKbps int) bool {
if maxKbps <= 0 {
return false
}
changed := false
if ci.MaxAudioBitrate == 0 || maxKbps < ci.MaxAudioBitrate {
ci.MaxAudioBitrate = maxKbps
changed = true
}
if ci.MaxTranscodingAudioBitrate == 0 || maxKbps < ci.MaxTranscodingAudioBitrate {
ci.MaxTranscodingAudioBitrate = maxKbps
changed = true
}
return changed
}
// ForceFormat narrows the client to transcoding to targetFormat and suppresses
// direct play, but only if the client already declares a profile for that
// format. All matching profiles are kept so negotiation can still pick among
// them (e.g. by protocol). Returns false (no-op) when targetFormat is empty or
// unsupported.
func (ci *ClientInfo) ForceFormat(targetFormat string) bool {
if targetFormat == "" {
return false
}
var matched []Profile
for i := range ci.TranscodingProfiles {
// matchesContainer is alias-aware, so a forced "oga" (legacy Opus
// target_format) still matches a resolved "opus" profile.
if _, format := resolveTargetFormat(&ci.TranscodingProfiles[i]); matchesContainer(format, []string{targetFormat}) {
matched = append(matched, ci.TranscodingProfiles[i])
}
}
if len(matched) == 0 {
return false
}
ci.TranscodingProfiles = matched
ci.DirectPlayProfiles = nil
return true
}
// DirectPlayProfile describes a format the client can play directly
type DirectPlayProfile struct {
Containers []string

133
core/stream/types_test.go Normal file
View File

@ -0,0 +1,133 @@
package stream
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("ClientInfo", func() {
Describe("CapBitrate", func() {
It("is a no-op when maxKbps is zero", func() {
ci := &ClientInfo{MaxAudioBitrate: 320, MaxTranscodingAudioBitrate: 320}
Expect(ci.CapBitrate(0)).To(BeFalse())
Expect(ci.MaxAudioBitrate).To(Equal(320))
Expect(ci.MaxTranscodingAudioBitrate).To(Equal(320))
})
It("is a no-op when maxKbps is negative", func() {
ci := &ClientInfo{MaxAudioBitrate: 320, MaxTranscodingAudioBitrate: 320}
Expect(ci.CapBitrate(-1)).To(BeFalse())
Expect(ci.MaxAudioBitrate).To(Equal(320))
Expect(ci.MaxTranscodingAudioBitrate).To(Equal(320))
})
It("sets both limits when both are zero (unlimited)", func() {
ci := &ClientInfo{}
Expect(ci.CapBitrate(256)).To(BeTrue())
Expect(ci.MaxAudioBitrate).To(Equal(256))
Expect(ci.MaxTranscodingAudioBitrate).To(Equal(256))
})
It("lowers limits higher than maxKbps", func() {
ci := &ClientInfo{MaxAudioBitrate: 320, MaxTranscodingAudioBitrate: 500}
Expect(ci.CapBitrate(192)).To(BeTrue())
Expect(ci.MaxAudioBitrate).To(Equal(192))
Expect(ci.MaxTranscodingAudioBitrate).To(Equal(192))
})
It("does not raise limits lower than maxKbps", func() {
ci := &ClientInfo{MaxAudioBitrate: 128, MaxTranscodingAudioBitrate: 96}
Expect(ci.CapBitrate(320)).To(BeFalse())
Expect(ci.MaxAudioBitrate).To(Equal(128))
Expect(ci.MaxTranscodingAudioBitrate).To(Equal(96))
})
It("reports changed when only one limit is lowered", func() {
ci := &ClientInfo{MaxAudioBitrate: 320, MaxTranscodingAudioBitrate: 128}
Expect(ci.CapBitrate(192)).To(BeTrue())
Expect(ci.MaxAudioBitrate).To(Equal(192))
Expect(ci.MaxTranscodingAudioBitrate).To(Equal(128))
})
It("caps only the zero (unlimited) limit", func() {
ci := &ClientInfo{MaxAudioBitrate: 128, MaxTranscodingAudioBitrate: 0}
Expect(ci.CapBitrate(192)).To(BeTrue())
Expect(ci.MaxAudioBitrate).To(Equal(128))
Expect(ci.MaxTranscodingAudioBitrate).To(Equal(192))
})
})
Describe("ForceFormat", func() {
It("restricts to the forced format and clears direct play when supported", func() {
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}, AudioCodecs: []string{"flac"}}},
TranscodingProfiles: []Profile{
{Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP},
{Container: "ogg", AudioCodec: "opus", Protocol: ProtocolHTTP},
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
},
}
ok := ci.ForceFormat("opus")
Expect(ok).To(BeTrue())
Expect(ci.TranscodingProfiles).To(HaveLen(1))
Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus"))
Expect(ci.DirectPlayProfiles).To(BeEmpty())
})
It("matches a container-only forced format (mp3)", func() {
ci := &ClientInfo{
TranscodingProfiles: []Profile{
{Container: "ogg", AudioCodec: "opus", Protocol: ProtocolHTTP},
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
},
}
ok := ci.ForceFormat("mp3")
Expect(ok).To(BeTrue())
Expect(ci.TranscodingProfiles).To(HaveLen(1))
Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3"))
})
It("matches the forced format against codec aliases (oga/opus)", func() {
ci := &ClientInfo{
TranscodingProfiles: []Profile{
{Container: "ogg", AudioCodec: "opus", Protocol: ProtocolHTTP},
{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP},
},
}
// Legacy DBs may store the Opus transcoding as target_format "oga".
ok := ci.ForceFormat("oga")
Expect(ok).To(BeTrue())
Expect(ci.TranscodingProfiles).To(HaveLen(1))
Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus"))
})
It("is a no-op when the forced format is not supported by the client", func() {
original := []Profile{{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}}
ci := &ClientInfo{
DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}}},
TranscodingProfiles: original,
}
ok := ci.ForceFormat("opus")
Expect(ok).To(BeFalse())
Expect(ci.TranscodingProfiles).To(Equal(original))
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
})
It("is a no-op for an empty target format", func() {
ci := &ClientInfo{TranscodingProfiles: []Profile{{Container: "mp3", AudioCodec: "mp3"}}}
Expect(ci.ForceFormat("")).To(BeFalse())
})
It("keeps all matching profiles when multiple resolve to the forced format", func() {
first := Profile{Container: "ogg", AudioCodec: "opus", Protocol: ProtocolHTTP}
second := Profile{Container: "ogg", AudioCodec: "opus", Protocol: ProtocolHTTP, MaxAudioChannels: 2}
other := Profile{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}
ci := &ClientInfo{TranscodingProfiles: []Profile{first, other, second}}
ok := ci.ForceFormat("opus")
Expect(ok).To(BeTrue())
Expect(ci.TranscodingProfiles).To(ConsistOf(first, second))
})
})
})

View File

@ -12,9 +12,9 @@ func init() {
goose.AddMigrationContext(Up20200130083147, Down20200130083147)
}
func Up20200130083147(_ context.Context, tx *sql.Tx) error {
func Up20200130083147(ctx context.Context, tx *sql.Tx) error {
log.Info("Creating DB Schema")
_, err := tx.Exec(`
_, err := tx.ExecContext(ctx, `
create table if not exists album
(
id varchar(255) not null
@ -179,6 +179,6 @@ create table if not exists user
return err
}
func Down20200130083147(_ context.Context, tx *sql.Tx) error {
func Down20200130083147(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,8 +11,8 @@ func init() {
goose.AddMigrationContext(Up20200131183653, Down20200131183653)
}
func Up20200131183653(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func Up20200131183653(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
create table search_dg_tmp
(
id varchar(255) not null
@ -37,8 +37,8 @@ update annotation set item_type = 'media_file' where item_type = 'mediaFile';
return err
}
func Down20200131183653(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func Down20200131183653(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
create table search_dg_tmp
(
id varchar(255) not null

View File

@ -11,8 +11,8 @@ func init() {
goose.AddMigrationContext(Up20200208222418, Down20200208222418)
}
func Up20200208222418(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func Up20200208222418(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
update annotation set play_count = 0 where play_count is null;
update annotation set rating = 0 where rating is null;
create table annotation_dg_tmp
@ -51,6 +51,6 @@ create index annotation_starred
return err
}
func Down20200208222418(_ context.Context, tx *sql.Tx) error {
func Down20200208222418(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,9 +11,9 @@ func init() {
goose.AddMigrationContext(Up20200220143731, Down20200220143731)
}
func Up20200220143731(_ context.Context, tx *sql.Tx) error {
notice(tx, "This migration will force the next scan to be a full rescan!")
_, err := tx.Exec(`
func Up20200220143731(ctx context.Context, tx *sql.Tx) error {
notice(ctx, tx, "This migration will force the next scan to be a full rescan!")
_, err := tx.ExecContext(ctx, `
create table media_file_dg_tmp
(
id varchar(255) not null
@ -125,6 +125,6 @@ update media_file set updated_at = '0001-01-01';
return err
}
func Down20200220143731(_ context.Context, tx *sql.Tx) error {
func Down20200220143731(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,11 +11,11 @@ func init() {
goose.AddMigrationContext(Up20200310171621, Down20200310171621)
}
func Up20200310171621(_ context.Context, tx *sql.Tx) error {
notice(tx, "A full rescan will be performed to enable search by Album Artist!")
return forceFullRescan(tx)
func Up20200310171621(ctx context.Context, tx *sql.Tx) error {
notice(ctx, tx, "A full rescan will be performed to enable search by Album Artist!")
return forceFullRescan(ctx, tx)
}
func Down20200310171621(_ context.Context, tx *sql.Tx) error {
func Down20200310171621(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,8 +11,8 @@ func init() {
goose.AddMigrationContext(Up20200310181627, Down20200310181627)
}
func Up20200310181627(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func Up20200310181627(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
create table transcoding
(
id varchar(255) not null primary key,
@ -45,8 +45,8 @@ create table player
return err
}
func Down20200310181627(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func Down20200310181627(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
drop table transcoding;
drop table player;
`)

View File

@ -11,8 +11,8 @@ func init() {
goose.AddMigrationContext(Up20200319211049, Down20200319211049)
}
func Up20200319211049(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func Up20200319211049(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
alter table media_file
add full_text varchar(255) default '';
create index if not exists media_file_full_text
@ -33,10 +33,10 @@ drop table if exists search;
if err != nil {
return err
}
notice(tx, "A full rescan will be performed!")
return forceFullRescan(tx)
notice(ctx, tx, "A full rescan will be performed!")
return forceFullRescan(ctx, tx)
}
func Down20200319211049(_ context.Context, tx *sql.Tx) error {
func Down20200319211049(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,8 +11,8 @@ func init() {
goose.AddMigrationContext(Up20200325185135, Down20200325185135)
}
func Up20200325185135(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func Up20200325185135(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
alter table album
add album_artist_id varchar(255) default '';
create index album_artist_album_id
@ -26,10 +26,10 @@ create index media_file_artist_album_id
if err != nil {
return err
}
notice(tx, "A full rescan will be performed!")
return forceFullRescan(tx)
notice(ctx, tx, "A full rescan will be performed!")
return forceFullRescan(ctx, tx)
}
func Down20200325185135(_ context.Context, tx *sql.Tx) error {
func Down20200325185135(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,11 +11,11 @@ func init() {
goose.AddMigrationContext(Up20200326090707, Down20200326090707)
}
func Up20200326090707(_ context.Context, tx *sql.Tx) error {
notice(tx, "A full rescan will be performed!")
return forceFullRescan(tx)
func Up20200326090707(ctx context.Context, tx *sql.Tx) error {
notice(ctx, tx, "A full rescan will be performed!")
return forceFullRescan(ctx, tx)
}
func Down20200326090707(_ context.Context, tx *sql.Tx) error {
func Down20200326090707(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,8 +11,8 @@ func init() {
goose.AddMigrationContext(Up20200327193744, Down20200327193744)
}
func Up20200327193744(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func Up20200327193744(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
create table album_dg_tmp
(
id varchar(255) not null
@ -72,10 +72,10 @@ create index album_max_year
if err != nil {
return err
}
notice(tx, "A full rescan will be performed!")
return forceFullRescan(tx)
notice(ctx, tx, "A full rescan will be performed!")
return forceFullRescan(ctx, tx)
}
func Down20200327193744(_ context.Context, tx *sql.Tx) error {
func Down20200327193744(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,8 +11,8 @@ func init() {
goose.AddMigrationContext(Up20200404214704, Down20200404214704)
}
func Up20200404214704(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func Up20200404214704(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
create index if not exists media_file_year
on media_file (year);
@ -25,6 +25,6 @@ create index if not exists media_file_track_number
return err
}
func Down20200404214704(_ context.Context, tx *sql.Tx) error {
func Down20200404214704(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,11 +11,11 @@ func init() {
goose.AddMigrationContext(Up20200409002249, Down20200409002249)
}
func Up20200409002249(_ context.Context, tx *sql.Tx) error {
notice(tx, "A full rescan will be performed to enable search by individual Artist in an Album!")
return forceFullRescan(tx)
func Up20200409002249(ctx context.Context, tx *sql.Tx) error {
notice(ctx, tx, "A full rescan will be performed to enable search by individual Artist in an Album!")
return forceFullRescan(ctx, tx)
}
func Down20200409002249(_ context.Context, tx *sql.Tx) error {
func Down20200409002249(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,8 +11,8 @@ func init() {
goose.AddMigrationContext(Up20200411164603, Down20200411164603)
}
func Up20200411164603(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func Up20200411164603(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
alter table playlist
add created_at datetime;
alter table playlist
@ -23,6 +23,6 @@ update playlist
return err
}
func Down20200411164603(_ context.Context, tx *sql.Tx) error {
func Down20200411164603(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,11 +11,11 @@ func init() {
goose.AddMigrationContext(Up20200418110522, Down20200418110522)
}
func Up20200418110522(_ context.Context, tx *sql.Tx) error {
notice(tx, "A full rescan will be performed to fix search Albums by year")
return forceFullRescan(tx)
func Up20200418110522(ctx context.Context, tx *sql.Tx) error {
notice(ctx, tx, "A full rescan will be performed to fix search Albums by year")
return forceFullRescan(ctx, tx)
}
func Down20200418110522(_ context.Context, tx *sql.Tx) error {
func Down20200418110522(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,11 +11,11 @@ func init() {
goose.AddMigrationContext(Up20200419222708, Down20200419222708)
}
func Up20200419222708(_ context.Context, tx *sql.Tx) error {
notice(tx, "A full rescan will be performed to change the search behaviour")
return forceFullRescan(tx)
func Up20200419222708(ctx context.Context, tx *sql.Tx) error {
notice(ctx, tx, "A full rescan will be performed to change the search behaviour")
return forceFullRescan(ctx, tx)
}
func Down20200419222708(_ context.Context, tx *sql.Tx) error {
func Down20200419222708(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,8 +11,8 @@ func init() {
goose.AddMigrationContext(Up20200423204116, Down20200423204116)
}
func Up20200423204116(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func Up20200423204116(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
alter table artist
add order_artist_name varchar(255) collate nocase;
alter table artist
@ -57,10 +57,10 @@ create index if not exists media_file_order_artist_name
if err != nil {
return err
}
notice(tx, "A full rescan will be performed to change the search behaviour")
return forceFullRescan(tx)
notice(ctx, tx, "A full rescan will be performed to change the search behaviour")
return forceFullRescan(ctx, tx)
}
func Down20200423204116(_ context.Context, tx *sql.Tx) error {
func Down20200423204116(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,18 +11,18 @@ func init() {
goose.AddMigrationContext(Up20200508093059, Down20200508093059)
}
func Up20200508093059(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func Up20200508093059(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
alter table artist
add song_count integer default 0 not null;
`)
if err != nil {
return err
}
notice(tx, "A full rescan will be performed to calculate artists' song counts")
return forceFullRescan(tx)
notice(ctx, tx, "A full rescan will be performed to calculate artists' song counts")
return forceFullRescan(ctx, tx)
}
func Down20200508093059(_ context.Context, tx *sql.Tx) error {
func Down20200508093059(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,18 +11,18 @@ func init() {
goose.AddMigrationContext(Up20200512104202, Down20200512104202)
}
func Up20200512104202(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func Up20200512104202(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
alter table media_file
add disc_subtitle varchar(255);
`)
if err != nil {
return err
}
notice(tx, "A full rescan will be performed to import disc subtitles")
return forceFullRescan(tx)
notice(ctx, tx, "A full rescan will be performed to import disc subtitles")
return forceFullRescan(ctx, tx)
}
func Down20200512104202(_ context.Context, tx *sql.Tx) error {
func Down20200512104202(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -13,8 +13,8 @@ func init() {
goose.AddMigrationContext(Up20200516140647, Down20200516140647)
}
func Up20200516140647(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func Up20200516140647(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
create table if not exists playlist_tracks
(
id integer default 0 not null,
@ -28,7 +28,7 @@ create unique index if not exists playlist_tracks_pos
if err != nil {
return err
}
rows, err := tx.Query("select id, tracks from playlist")
rows, err := tx.QueryContext(ctx, "select id, tracks from playlist")
if err != nil {
return err
}
@ -49,7 +49,7 @@ create unique index if not exists playlist_tracks_pos
return err
}
_, err = tx.Exec(`
_, err = tx.ExecContext(ctx, `
create table playlist_dg_tmp
(
id varchar(255) not null
@ -96,6 +96,6 @@ func Up20200516140647UpdatePlaylistTracks(tx *sql.Tx, id string, tracks string)
return nil
}
func Down20200516140647(_ context.Context, tx *sql.Tx) error {
func Down20200516140647(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,46 +11,46 @@ func init() {
goose.AddMigrationContext(Up20200608153717, Down20200608153717)
}
func Up20200608153717(_ context.Context, tx *sql.Tx) error {
func Up20200608153717(ctx context.Context, tx *sql.Tx) error {
// First delete dangling players
_, err := tx.Exec(`
_, err := tx.ExecContext(ctx, `
delete from player where user_name not in (select user_name from user)`)
if err != nil {
return err
}
// Also delete dangling players
_, err = tx.Exec(`
_, err = tx.ExecContext(ctx, `
delete from playlist where owner not in (select user_name from user)`)
if err != nil {
return err
}
// Also delete dangling playlist tracks
_, err = tx.Exec(`
_, err = tx.ExecContext(ctx, `
delete from playlist_tracks where playlist_id not in (select id from playlist)`)
if err != nil {
return err
}
// Add foreign key to player table
err = updatePlayer_20200608153717(tx)
err = updatePlayer_20200608153717(ctx, tx)
if err != nil {
return err
}
// Add foreign key to playlist table
err = updatePlaylist_20200608153717(tx)
err = updatePlaylist_20200608153717(ctx, tx)
if err != nil {
return err
}
// Add foreign keys to playlist_tracks table
return updatePlaylistTracks_20200608153717(tx)
return updatePlaylistTracks_20200608153717(ctx, tx)
}
func updatePlayer_20200608153717(tx *sql.Tx) error {
_, err := tx.Exec(`
func updatePlayer_20200608153717(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
create table player_dg_tmp
(
id varchar(255) not null
@ -77,8 +77,8 @@ alter table player_dg_tmp rename to player;
return err
}
func updatePlaylist_20200608153717(tx *sql.Tx) error {
_, err := tx.Exec(`
func updatePlaylist_20200608153717(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
create table playlist_dg_tmp
(
id varchar(255) not null
@ -108,8 +108,8 @@ create index playlist_name
return err
}
func updatePlaylistTracks_20200608153717(tx *sql.Tx) error {
_, err := tx.Exec(`
func updatePlaylistTracks_20200608153717(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
create table playlist_tracks_dg_tmp
(
id integer default 0 not null,
@ -133,6 +133,6 @@ create unique index playlist_tracks_pos
return err
}
func Down20200608153717(_ context.Context, tx *sql.Tx) error {
func Down20200608153717(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -13,8 +13,8 @@ func init() {
goose.AddMigrationContext(upAddDefaultTranscodings, downAddDefaultTranscodings)
}
func upAddDefaultTranscodings(_ context.Context, tx *sql.Tx) error {
row := tx.QueryRow("SELECT COUNT(*) FROM transcoding")
func upAddDefaultTranscodings(ctx context.Context, tx *sql.Tx) error {
row := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM transcoding")
var count int
err := row.Scan(&count)
if err != nil {
@ -38,6 +38,6 @@ func upAddDefaultTranscodings(_ context.Context, tx *sql.Tx) error {
return nil
}
func downAddDefaultTranscodings(_ context.Context, tx *sql.Tx) error {
func downAddDefaultTranscodings(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,8 +11,8 @@ func init() {
goose.AddMigrationContext(upAddPlaylistPath, downAddPlaylistPath)
}
func upAddPlaylistPath(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func upAddPlaylistPath(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
alter table playlist
add path string default '' not null;
@ -23,6 +23,6 @@ alter table playlist
return err
}
func downAddPlaylistPath(_ context.Context, tx *sql.Tx) error {
func downAddPlaylistPath(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,8 +11,8 @@ func init() {
goose.AddMigrationContext(upCreatePlayQueuesTable, downCreatePlayQueuesTable)
}
func upCreatePlayQueuesTable(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func upCreatePlayQueuesTable(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
create table playqueue
(
id varchar(255) not null primary key,
@ -32,6 +32,6 @@ create table playqueue
return err
}
func downCreatePlayQueuesTable(_ context.Context, tx *sql.Tx) error {
func downCreatePlayQueuesTable(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,8 +11,8 @@ func init() {
goose.AddMigrationContext(upCreateBookmarkTable, downCreateBookmarkTable)
}
func upCreateBookmarkTable(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func upCreateBookmarkTable(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
create table bookmark
(
user_id varchar(255) not null
@ -49,6 +49,6 @@ alter table playqueue_dg_tmp rename to playqueue;
return err
}
func downCreateBookmarkTable(_ context.Context, tx *sql.Tx) error {
func downCreateBookmarkTable(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,8 +11,8 @@ func init() {
goose.AddMigrationContext(upDropEmailUniqueConstraint, downDropEmailUniqueConstraint)
}
func upDropEmailUniqueConstraint(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func upDropEmailUniqueConstraint(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
create table user_dg_tmp
(
id varchar(255) not null
@ -38,6 +38,6 @@ alter table user_dg_tmp rename to user;
return err
}
func downDropEmailUniqueConstraint(_ context.Context, tx *sql.Tx) error {
func downDropEmailUniqueConstraint(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,14 +11,14 @@ func init() {
goose.AddMigrationContext(Up20201003111749, Down20201003111749)
}
func Up20201003111749(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func Up20201003111749(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
create index if not exists annotation_starred_at
on annotation (starred_at);
`)
return err
}
func Down20201003111749(_ context.Context, tx *sql.Tx) error {
func Down20201003111749(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,8 +11,8 @@ func init() {
goose.AddMigrationContext(Up20201010162350, Down20201010162350)
}
func Up20201010162350(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func Up20201010162350(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
alter table album
add size integer default 0 not null;
create index if not exists album_size
@ -28,7 +28,7 @@ where id not null;`)
return err
}
func Down20201010162350(_ context.Context, tx *sql.Tx) error {
func Down20201010162350(ctx context.Context, tx *sql.Tx) error {
// This code is executed when the migration is rolled back.
return nil
}

View File

@ -11,8 +11,8 @@ func init() {
goose.AddMigrationContext(Up20201012210022, Down20201012210022)
}
func Up20201012210022(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func Up20201012210022(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
alter table artist
add size integer default 0 not null;
create index if not exists artist_size
@ -40,6 +40,6 @@ update playlist set size = ifnull((
return err
}
func Down20201012210022(_ context.Context, tx *sql.Tx) error {
func Down20201012210022(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,8 +11,8 @@ func init() {
goose.AddMigrationContext(Up20201021085410, Down20201021085410)
}
func Up20201021085410(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func Up20201021085410(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
alter table media_file
add mbz_track_id varchar(255);
alter table media_file
@ -49,11 +49,11 @@ alter table artist
if err != nil {
return err
}
notice(tx, "A full rescan needs to be performed to import more tags")
return forceFullRescan(tx)
notice(ctx, tx, "A full rescan needs to be performed to import more tags")
return forceFullRescan(ctx, tx)
}
func Down20201021085410(_ context.Context, tx *sql.Tx) error {
func Down20201021085410(ctx context.Context, tx *sql.Tx) error {
// This code is executed when the migration is rolled back.
return nil
}

View File

@ -11,8 +11,8 @@ func init() {
goose.AddMigrationContext(Up20201021093209, Down20201021093209)
}
func Up20201021093209(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func Up20201021093209(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
create index if not exists media_file_artist
on media_file (artist);
create index if not exists media_file_album_artist
@ -23,6 +23,6 @@ create index if not exists media_file_mbz_track_id
return err
}
func Down20201021093209(_ context.Context, tx *sql.Tx) error {
func Down20201021093209(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,14 +11,14 @@ func init() {
goose.AddMigrationContext(Up20201021135455, Down20201021135455)
}
func Up20201021135455(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func Up20201021135455(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
create index if not exists media_file_artist_id
on media_file (artist_id);
`)
return err
}
func Down20201021135455(_ context.Context, tx *sql.Tx) error {
func Down20201021135455(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,8 +11,8 @@ func init() {
goose.AddMigrationContext(upAddArtistImageUrl, downAddArtistImageUrl)
}
func upAddArtistImageUrl(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func upAddArtistImageUrl(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
alter table artist
add biography varchar(255) default '' not null;
alter table artist
@ -31,6 +31,6 @@ alter table artist
return err
}
func downAddArtistImageUrl(_ context.Context, tx *sql.Tx) error {
func downAddArtistImageUrl(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,8 +11,8 @@ func init() {
goose.AddMigrationContext(Up20201110205344, Down20201110205344)
}
func Up20201110205344(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func Up20201110205344(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
alter table media_file
add comment varchar;
alter table media_file
@ -24,10 +24,10 @@ alter table album
if err != nil {
return err
}
notice(tx, "A full rescan will be performed to import comments and lyrics")
return forceFullRescan(tx)
notice(ctx, tx, "A full rescan will be performed to import comments and lyrics")
return forceFullRescan(ctx, tx)
}
func Down20201110205344(_ context.Context, tx *sql.Tx) error {
func Down20201110205344(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,14 +11,14 @@ func init() {
goose.AddMigrationContext(Up20201128100726, Down20201128100726)
}
func Up20201128100726(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func Up20201128100726(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
alter table player
add report_real_path bool default FALSE not null;
`)
return err
}
func Down20201128100726(_ context.Context, tx *sql.Tx) error {
func Down20201128100726(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -13,8 +13,8 @@ func init() {
goose.AddMigrationContext(Up20201213124814, Down20201213124814)
}
func Up20201213124814(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func Up20201213124814(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
alter table album
add all_artist_ids varchar;
@ -25,11 +25,11 @@ create index if not exists album_all_artist_ids
return err
}
return updateAlbums20201213124814(tx)
return updateAlbums20201213124814(ctx, tx)
}
func updateAlbums20201213124814(tx *sql.Tx) error {
rows, err := tx.Query(`
func updateAlbums20201213124814(ctx context.Context, tx *sql.Tx) error {
rows, err := tx.QueryContext(ctx, `
select a.id, a.name, a.artist_id, a.album_artist_id, group_concat(mf.artist_id, ' ')
from album a left join media_file mf on a.id = mf.album_id group by a.id
`)
@ -59,6 +59,6 @@ select a.id, a.name, a.artist_id, a.album_artist_id, group_concat(mf.artist_id,
return rows.Err()
}
func Down20201213124814(_ context.Context, tx *sql.Tx) error {
func Down20201213124814(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,8 +11,8 @@ func init() {
goose.AddMigrationContext(upAddTimestampIndexesGo, downAddTimestampIndexesGo)
}
func upAddTimestampIndexesGo(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func upAddTimestampIndexesGo(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
create index if not exists album_updated_at
on album (updated_at);
create index if not exists album_created_at
@ -29,6 +29,6 @@ create index if not exists media_file_updated_at
return err
}
func downAddTimestampIndexesGo(_ context.Context, tx *sql.Tx) error {
func downAddTimestampIndexesGo(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -14,10 +14,10 @@ func init() {
goose.AddMigrationContext(upFixAlbumComments, downFixAlbumComments)
}
func upFixAlbumComments(_ context.Context, tx *sql.Tx) error {
func upFixAlbumComments(ctx context.Context, tx *sql.Tx) error {
//nolint:gosec
rows, err := tx.Query(`
SELECT album.id, group_concat(media_file.comment, '` + consts.Zwsp + `') FROM album, media_file WHERE media_file.album_id = album.id GROUP BY album.id;
rows, err := tx.QueryContext(ctx, `
SELECT album.id, group_concat(media_file.comment, '`+consts.Zwsp+`') FROM album, media_file WHERE media_file.album_id = album.id GROUP BY album.id;
`)
if err != nil {
return err
@ -49,7 +49,7 @@ func upFixAlbumComments(_ context.Context, tx *sql.Tx) error {
return rows.Err()
}
func downFixAlbumComments(_ context.Context, tx *sql.Tx) error {
func downFixAlbumComments(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,8 +11,8 @@ func init() {
goose.AddMigrationContext(upAddBpmMetadata, downAddBpmMetadata)
}
func upAddBpmMetadata(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func upAddBpmMetadata(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
alter table media_file
add bpm integer;
@ -22,10 +22,10 @@ create index if not exists media_file_bpm
if err != nil {
return err
}
notice(tx, "A full rescan needs to be performed to import more tags")
return forceFullRescan(tx)
notice(ctx, tx, "A full rescan needs to be performed to import more tags")
return forceFullRescan(ctx, tx)
}
func downAddBpmMetadata(_ context.Context, tx *sql.Tx) error {
func downAddBpmMetadata(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,8 +11,8 @@ func init() {
goose.AddMigrationContext(upCreateSharesTable, downCreateSharesTable)
}
func upCreateSharesTable(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func upCreateSharesTable(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
create table share
(
id varchar(255) not null primary key,
@ -30,6 +30,6 @@ create table share
return err
}
func downCreateSharesTable(_ context.Context, tx *sql.Tx) error {
func downCreateSharesTable(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,8 +11,8 @@ func init() {
goose.AddMigrationContext(upUpdateShareFieldNames, downUpdateShareFieldNames)
}
func upUpdateShareFieldNames(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func upUpdateShareFieldNames(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
alter table share rename column expires to expires_at;
alter table share rename column created to created_at;
alter table share rename column last_visited to last_visited_at;
@ -21,6 +21,6 @@ alter table share rename column last_visited to last_visited_at;
return err
}
func downUpdateShareFieldNames(_ context.Context, tx *sql.Tx) error {
func downUpdateShareFieldNames(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -16,7 +16,7 @@ func init() {
}
func upEncodeAllPasswords(ctx context.Context, tx *sql.Tx) error {
rows, err := tx.Query(`SELECT id, user_name, password from user;`)
rows, err := tx.QueryContext(ctx, `SELECT id, user_name, password from user;`)
if err != nil {
return err
}
@ -51,6 +51,6 @@ func upEncodeAllPasswords(ctx context.Context, tx *sql.Tx) error {
return rows.Err()
}
func downEncodeAllPasswords(_ context.Context, tx *sql.Tx) error {
func downEncodeAllPasswords(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,8 +11,8 @@ func init() {
goose.AddMigrationContext(upDropPlayerNameUniqueConstraint, downDropPlayerNameUniqueConstraint)
}
func upDropPlayerNameUniqueConstraint(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func upDropPlayerNameUniqueConstraint(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
create table player_dg_tmp
(
id varchar(255) not null
@ -43,6 +43,6 @@ create index if not exists player_name
return err
}
func downDropPlayerNameUniqueConstraint(_ context.Context, tx *sql.Tx) error {
func downDropPlayerNameUniqueConstraint(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,16 +11,16 @@ func init() {
goose.AddMigrationContext(upAddUserPrefsPlayerScrobblerEnabled, downAddUserPrefsPlayerScrobblerEnabled)
}
func upAddUserPrefsPlayerScrobblerEnabled(_ context.Context, tx *sql.Tx) error {
err := upAddUserPrefs(tx)
func upAddUserPrefsPlayerScrobblerEnabled(ctx context.Context, tx *sql.Tx) error {
err := upAddUserPrefs(ctx, tx)
if err != nil {
return err
}
return upPlayerScrobblerEnabled(tx)
return upPlayerScrobblerEnabled(ctx, tx)
}
func upAddUserPrefs(tx *sql.Tx) error {
_, err := tx.Exec(`
func upAddUserPrefs(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
create table user_props
(
user_id varchar not null,
@ -33,13 +33,13 @@ create table user_props
return err
}
func upPlayerScrobblerEnabled(tx *sql.Tx) error {
_, err := tx.Exec(`
func upPlayerScrobblerEnabled(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
alter table player add scrobble_enabled bool default true;
`)
return err
}
func downAddUserPrefsPlayerScrobblerEnabled(_ context.Context, tx *sql.Tx) error {
func downAddUserPrefsPlayerScrobblerEnabled(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,8 +11,8 @@ func init() {
goose.AddMigrationContext(upAddReferentialIntegrityToUserProps, downAddReferentialIntegrityToUserProps)
}
func upAddReferentialIntegrityToUserProps(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func upAddReferentialIntegrityToUserProps(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
create table user_props_dg_tmp
(
user_id varchar not null
@ -34,6 +34,6 @@ alter table user_props_dg_tmp rename to user_props;
return err
}
func downAddReferentialIntegrityToUserProps(_ context.Context, tx *sql.Tx) error {
func downAddReferentialIntegrityToUserProps(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,8 +11,8 @@ func init() {
goose.AddMigrationContext(upAddScrobbleBuffer, downAddScrobbleBuffer)
}
func upAddScrobbleBuffer(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func upAddScrobbleBuffer(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
create table if not exists scrobble_buffer
(
user_id varchar not null
@ -34,6 +34,6 @@ create table if not exists scrobble_buffer
return err
}
func downAddScrobbleBuffer(_ context.Context, tx *sql.Tx) error {
func downAddScrobbleBuffer(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,9 +11,9 @@ func init() {
goose.AddMigrationContext(upAddGenreTables, downAddGenreTables)
}
func upAddGenreTables(_ context.Context, tx *sql.Tx) error {
notice(tx, "A full rescan will be performed to import multiple genres!")
_, err := tx.Exec(`
func upAddGenreTables(ctx context.Context, tx *sql.Tx) error {
notice(ctx, tx, "A full rescan will be performed to import multiple genres!")
_, err := tx.ExecContext(ctx, `
create table if not exists genre
(
id varchar not null primary key,
@ -61,9 +61,9 @@ create table if not exists artist_genres
if err != nil {
return err
}
return forceFullRescan(tx)
return forceFullRescan(ctx, tx)
}
func downAddGenreTables(_ context.Context, tx *sql.Tx) error {
func downAddGenreTables(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,8 +11,8 @@ func init() {
goose.AddMigrationContext(upAddMediafileChannels, downAddMediafileChannels)
}
func upAddMediafileChannels(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func upAddMediafileChannels(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
alter table media_file
add channels integer;
@ -22,10 +22,10 @@ create index if not exists media_file_channels
if err != nil {
return err
}
notice(tx, "A full rescan needs to be performed to import more tags")
return forceFullRescan(tx)
notice(ctx, tx, "A full rescan needs to be performed to import more tags")
return forceFullRescan(ctx, tx)
}
func downAddMediafileChannels(_ context.Context, tx *sql.Tx) error {
func downAddMediafileChannels(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,8 +11,8 @@ func init() {
goose.AddMigrationContext(upAddSmartPlaylist, downAddSmartPlaylist)
}
func upAddSmartPlaylist(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func upAddSmartPlaylist(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
alter table playlist
add column rules varchar null;
alter table playlist
@ -33,6 +33,6 @@ create unique index playlist_fields_idx
return err
}
func downAddSmartPlaylist(_ context.Context, tx *sql.Tx) error {
func downAddSmartPlaylist(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -14,8 +14,8 @@ func init() {
goose.AddMigrationContext(upAddOrderTitleToMediaFile, downAddOrderTitleToMediaFile)
}
func upAddOrderTitleToMediaFile(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func upAddOrderTitleToMediaFile(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
alter table main.media_file
add order_title varchar null collate NOCASE;
create index if not exists media_file_order_title
@ -25,12 +25,12 @@ create index if not exists media_file_order_title
return err
}
return upAddOrderTitleToMediaFile_populateOrderTitle(tx)
return upAddOrderTitleToMediaFile_populateOrderTitle(ctx, tx)
}
//goland:noinspection GoSnakeCaseUsage
func upAddOrderTitleToMediaFile_populateOrderTitle(tx *sql.Tx) error {
rows, err := tx.Query(`select id, title from media_file`)
func upAddOrderTitleToMediaFile_populateOrderTitle(ctx context.Context, tx *sql.Tx) error {
rows, err := tx.QueryContext(ctx, `select id, title from media_file`)
if err != nil {
return err
}
@ -57,6 +57,6 @@ func upAddOrderTitleToMediaFile_populateOrderTitle(tx *sql.Tx) error {
return rows.Err()
}
func downAddOrderTitleToMediaFile(_ context.Context, tx *sql.Tx) error {
func downAddOrderTitleToMediaFile(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -13,8 +13,8 @@ func init() {
goose.AddMigrationContext(upUnescapeLyricsAndComments, downUnescapeLyricsAndComments)
}
func upUnescapeLyricsAndComments(_ context.Context, tx *sql.Tx) error {
rows, err := tx.Query(`select id, comment, lyrics, title from media_file`)
func upUnescapeLyricsAndComments(ctx context.Context, tx *sql.Tx) error {
rows, err := tx.QueryContext(ctx, `select id, comment, lyrics, title from media_file`)
if err != nil {
return err
}
@ -43,6 +43,6 @@ func upUnescapeLyricsAndComments(_ context.Context, tx *sql.Tx) error {
return rows.Err()
}
func downUnescapeLyricsAndComments(_ context.Context, tx *sql.Tx) error {
func downUnescapeLyricsAndComments(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,8 +11,8 @@ func init() {
goose.AddMigrationContext(upAddUseridToPlaylist, downAddUseridToPlaylist)
}
func upAddUseridToPlaylist(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func upAddUseridToPlaylist(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
create table playlist_dg_tmp
(
id varchar(255) not null
@ -56,6 +56,6 @@ create index playlist_updated_at
return err
}
func downAddUseridToPlaylist(_ context.Context, tx *sql.Tx) error {
func downAddUseridToPlaylist(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,14 +11,14 @@ func init() {
goose.AddMigrationContext(upAddAlphabeticalByArtistIndex, downAddAlphabeticalByArtistIndex)
}
func upAddAlphabeticalByArtistIndex(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func upAddAlphabeticalByArtistIndex(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
create index album_alphabetical_by_artist
ON album(compilation, order_album_artist_name, order_album_name)
`)
return err
}
func downAddAlphabeticalByArtistIndex(_ context.Context, tx *sql.Tx) error {
func downAddAlphabeticalByArtistIndex(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,13 +11,13 @@ func init() {
goose.AddMigrationContext(upRemoveInvalidArtistIds, downRemoveInvalidArtistIds)
}
func upRemoveInvalidArtistIds(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func upRemoveInvalidArtistIds(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
update media_file set artist_id = '' where not exists(select 1 from artist where id = artist_id)
`)
return err
}
func downRemoveInvalidArtistIds(_ context.Context, tx *sql.Tx) error {
func downRemoveInvalidArtistIds(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,19 +11,19 @@ func init() {
goose.AddMigrationContext(upAddMusicbrainzReleaseTrackId, downAddMusicbrainzReleaseTrackId)
}
func upAddMusicbrainzReleaseTrackId(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func upAddMusicbrainzReleaseTrackId(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
alter table media_file
add mbz_release_track_id varchar(255);
`)
if err != nil {
return err
}
notice(tx, "A full rescan needs to be performed to import more tags")
return forceFullRescan(tx)
notice(ctx, tx, "A full rescan needs to be performed to import more tags")
return forceFullRescan(ctx, tx)
}
func downAddMusicbrainzReleaseTrackId(_ context.Context, tx *sql.Tx) error {
func downAddMusicbrainzReleaseTrackId(ctx context.Context, tx *sql.Tx) error {
// This code is executed when the migration is rolled back.
return nil
}

View File

@ -11,17 +11,17 @@ func init() {
goose.AddMigrationContext(upAddAlbumImagePaths, downAddAlbumImagePaths)
}
func upAddAlbumImagePaths(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func upAddAlbumImagePaths(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
alter table main.album add image_files varchar;
`)
if err != nil {
return err
}
notice(tx, "A full rescan needs to be performed to import all album images")
return forceFullRescan(tx)
notice(ctx, tx, "A full rescan needs to be performed to import all album images")
return forceFullRescan(ctx, tx)
}
func downAddAlbumImagePaths(_ context.Context, tx *sql.Tx) error {
func downAddAlbumImagePaths(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,18 +11,18 @@ func init() {
goose.AddMigrationContext(upRemoveCoverArtId, downRemoveCoverArtId)
}
func upRemoveCoverArtId(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func upRemoveCoverArtId(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
alter table album drop column cover_art_id;
alter table album rename column cover_art_path to embed_art_path
`)
if err != nil {
return err
}
notice(tx, "A full rescan needs to be performed to import all album images")
return forceFullRescan(tx)
notice(ctx, tx, "A full rescan needs to be performed to import all album images")
return forceFullRescan(ctx, tx)
}
func downRemoveCoverArtId(_ context.Context, tx *sql.Tx) error {
func downRemoveCoverArtId(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -16,15 +16,15 @@ func init() {
goose.AddMigrationContext(upAddAlbumPaths, downAddAlbumPaths)
}
func upAddAlbumPaths(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`alter table album add paths varchar;`)
func upAddAlbumPaths(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `alter table album add paths varchar;`)
if err != nil {
return err
}
//nolint:gosec
rows, err := tx.Query(`
select album_id, group_concat(path, '` + consts.Zwsp + `') from media_file group by album_id
rows, err := tx.QueryContext(ctx, `
select album_id, group_concat(path, '`+consts.Zwsp+`') from media_file group by album_id
`)
if err != nil {
return err
@ -63,6 +63,6 @@ func upAddAlbumPathsDirs(filePaths string) string {
return strings.Join(dirs, string(filepath.ListSeparator))
}
func downAddAlbumPaths(_ context.Context, tx *sql.Tx) error {
func downAddAlbumPaths(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,11 +11,11 @@ func init() {
goose.AddMigrationContext(upTouchPlaylists, downTouchPlaylists)
}
func upTouchPlaylists(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`update playlist set updated_at = datetime('now');`)
func upTouchPlaylists(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `update playlist set updated_at = datetime('now');`)
return err
}
func downTouchPlaylists(_ context.Context, tx *sql.Tx) error {
func downTouchPlaylists(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,8 +11,8 @@ func init() {
goose.AddMigrationContext(upCreateInternetRadio, downCreateInternetRadio)
}
func upCreateInternetRadio(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func upCreateInternetRadio(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
create table if not exists radio
(
id varchar(255) not null primary key,
@ -26,6 +26,6 @@ create table if not exists radio
return err
}
func downCreateInternetRadio(_ context.Context, tx *sql.Tx) error {
func downCreateInternetRadio(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,8 +11,8 @@ func init() {
goose.AddMigrationContext(upAddReplaygainMetadata, downAddReplaygainMetadata)
}
func upAddReplaygainMetadata(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func upAddReplaygainMetadata(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
alter table media_file add
rg_album_gain real;
alter table media_file add
@ -26,10 +26,10 @@ alter table media_file add
return err
}
notice(tx, "A full rescan needs to be performed to import more tags")
return forceFullRescan(tx)
notice(ctx, tx, "A full rescan needs to be performed to import more tags")
return forceFullRescan(ctx, tx)
}
func downAddReplaygainMetadata(_ context.Context, tx *sql.Tx) error {
func downAddReplaygainMetadata(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,8 +11,8 @@ func init() {
goose.AddMigrationContext(upAddAlbumInfo, downAddAlbumInfo)
}
func upAddAlbumInfo(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func upAddAlbumInfo(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
alter table album
add description varchar(255) default '' not null;
alter table album
@ -29,6 +29,6 @@ alter table album
return err
}
func downAddAlbumInfo(_ context.Context, tx *sql.Tx) error {
func downAddAlbumInfo(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,8 +11,8 @@ func init() {
goose.AddMigrationContext(upAddMissingShareInfo, downAddMissingShareInfo)
}
func upAddMissingShareInfo(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func upAddMissingShareInfo(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
drop table if exists share;
create table share
(
@ -37,6 +37,6 @@ create table share
return err
}
func downAddMissingShareInfo(_ context.Context, tx *sql.Tx) error {
func downAddMissingShareInfo(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -16,10 +16,10 @@ func init() {
goose.AddMigrationContext(upChangePathListSeparator, downChangePathListSeparator)
}
func upChangePathListSeparator(_ context.Context, tx *sql.Tx) error {
func upChangePathListSeparator(ctx context.Context, tx *sql.Tx) error {
//nolint:gosec
rows, err := tx.Query(`
select album_id, group_concat(path, '` + consts.Zwsp + `') from media_file group by album_id
rows, err := tx.QueryContext(ctx, `
select album_id, group_concat(path, '`+consts.Zwsp+`') from media_file group by album_id
`)
if err != nil {
return err
@ -58,6 +58,6 @@ func upChangePathListSeparatorDirs(filePaths string) string {
return strings.Join(dirs, consts.Zwsp)
}
func downChangePathListSeparator(_ context.Context, tx *sql.Tx) error {
func downChangePathListSeparator(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -16,8 +16,8 @@ func init() {
goose.AddMigrationContext(upChangeImageFilesListSeparator, downChangeImageFilesListSeparator)
}
func upChangeImageFilesListSeparator(_ context.Context, tx *sql.Tx) error {
rows, err := tx.Query(`select id, image_files from album`)
func upChangeImageFilesListSeparator(ctx context.Context, tx *sql.Tx) error {
rows, err := tx.QueryContext(ctx, `select id, image_files from album`)
if err != nil {
return err
}
@ -54,7 +54,7 @@ func upChangeImageFilesListSeparatorDirs(filePaths string) string {
return strings.Join(allPaths, consts.Zwsp)
}
func downChangeImageFilesListSeparator(_ context.Context, tx *sql.Tx) error {
func downChangeImageFilesListSeparator(ctx context.Context, tx *sql.Tx) error {
// This code is executed when the migration is rolled back.
return nil
}

View File

@ -11,14 +11,14 @@ func init() {
goose.AddMigrationContext(upAddDownloadToShare, downAddDownloadToShare)
}
func upAddDownloadToShare(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func upAddDownloadToShare(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
alter table share
add downloadable bool not null default false;
`)
return err
}
func downAddDownloadToShare(_ context.Context, tx *sql.Tx) error {
func downAddDownloadToShare(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,8 +11,8 @@ func init() {
goose.AddMigrationContext(upAddRelRecYear, downAddRelRecYear)
}
func upAddRelRecYear(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func upAddRelRecYear(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
alter table media_file
add date varchar(255) default '' not null;
alter table media_file
@ -41,10 +41,10 @@ alter table album
return err
}
notice(tx, "A full rescan needs to be performed to import more tags")
return forceFullRescan(tx)
notice(ctx, tx, "A full rescan needs to be performed to import more tags")
return forceFullRescan(ctx, tx)
}
func downAddRelRecYear(_ context.Context, tx *sql.Tx) error {
func downAddRelRecYear(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,16 +11,16 @@ func init() {
goose.AddMigrationContext(upRenameMusicbrainzRecordingId, downRenameMusicbrainzRecordingId)
}
func upRenameMusicbrainzRecordingId(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func upRenameMusicbrainzRecordingId(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
alter table media_file
rename column mbz_track_id to mbz_recording_id;
`)
return err
}
func downRenameMusicbrainzRecordingId(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
func downRenameMusicbrainzRecordingId(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
alter table media_file
rename column mbz_recording_id to mbz_track_id;
`)

View File

@ -29,7 +29,7 @@ func upAlterLyricColumn(ctx context.Context, tx *sql.Tx) error {
return err
}
rows, err := tx.Query(`select id, lyrics_old FROM media_file WHERE lyrics_old <> '';`)
rows, err := tx.QueryContext(ctx, `select id, lyrics_old FROM media_file WHERE lyrics_old <> '';`)
if err != nil {
return err
}
@ -46,12 +46,12 @@ func upAlterLyricColumn(ctx context.Context, tx *sql.Tx) error {
continue
}
lyrics, err := model.ToLyrics("xxx", lyrics.String)
parsed, err := model.ParseLyrics(".lrc", "xxx", []byte(lyrics.String))
if err != nil {
return err
}
text, err := json.Marshal(model.LyricList{*lyrics})
text, err := json.Marshal(parsed)
if err != nil {
return err
}
@ -72,7 +72,7 @@ func upAlterLyricColumn(ctx context.Context, tx *sql.Tx) error {
return err
}
notice(tx, "A full rescan should be performed to pick up additional lyrics (existing lyrics have been preserved)")
notice(ctx, tx, "A full rescan should be performed to pick up additional lyrics (existing lyrics have been preserved)")
return nil
}

View File

@ -558,6 +558,6 @@ create index media_file_mbz_track_id
return err
}
func Down20240122223340(context.Context, *sql.Tx) error {
func Down20240122223340(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -19,7 +19,7 @@ alter table media_file
create index if not exists media_file_sample_rate
on media_file (sample_rate);
`)
notice(tx, "A full rescan should be performed to pick up additional tags")
notice(ctx, tx, "A full rescan should be performed to pick up additional tags")
return err
}

View File

@ -61,6 +61,6 @@ create index annotation_starred_at
return err
}
func downRemoveAnnotationId(ctx context.Context, tx *sql.Tx) error {
func downRemoveAnnotationId(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -97,8 +97,8 @@ insert into property (id, value) values ('PIDTrack', 'track_legacy') on conflict
insert into property (id, value) values ('PIDAlbum', 'album_legacy') on conflict do nothing;
`),
func() error {
notice(tx, "A full scan will be triggered to populate the new tables. This may take a while.")
return forceFullRescan(tx)
notice(ctx, tx, "A full scan will be triggered to populate the new tables. This may take a while.")
return forceFullRescan(ctx, tx)
},
)
}
@ -314,6 +314,6 @@ alter table artist
}
}
func downSupportNewScanner(context.Context, *sql.Tx) error {
func downSupportNewScanner(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -75,6 +75,6 @@ create table playqueue_dg_tmp(
return err
}
func downPlayQueueCurrentToIndex(ctx context.Context, tx *sql.Tx) error {
func downPlayQueueCurrentToIndex(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -16,6 +16,6 @@ func upAddFolderHash(ctx context.Context, tx *sql.Tx) error {
return err
}
func downAddFolderHash(ctx context.Context, tx *sql.Tx) error {
func downAddFolderHash(_ context.Context, _ *sql.Tx) error {
return nil
}

Some files were not shown because too many files have changed in this diff Show More