mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
Merge branch 'master' into album-image-gallery
This commit is contained in:
commit
dce4883654
@ -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
|
||||
|
||||
11
Dockerfile
11
Dockerfile
@ -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}
|
||||
|
||||
@ -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**
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 -",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@ -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/
|
||||
|
||||
@ -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{}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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{
|
||||
{
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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" }
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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},
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
133
core/stream/types_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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;
|
||||
`)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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;
|
||||
`)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user