Merge branch 'master' into pr-5076

This commit is contained in:
Deluan 2026-06-18 20:32:29 -04:00
commit 7889deb410
107 changed files with 1189 additions and 383 deletions

View File

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

View File

@ -156,25 +156,25 @@ var (
Name: "mp3 audio",
TargetFormat: "mp3",
DefaultBitRate: 192,
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -",
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -f mp3 -",
},
{
Name: "opus audio",
TargetFormat: "opus",
DefaultBitRate: 128,
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a libopus -f opus -",
},
{
Name: "aac audio",
TargetFormat: "aac",
DefaultBitRate: 256,
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
},
{
Name: "flac audio",
TargetFormat: "flac",
DefaultBitRate: 0,
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -",
Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -v 0 -c:a flac -f flac -",
},
}
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -29,7 +29,7 @@ func upAlterLyricColumn(ctx context.Context, tx *sql.Tx) error {
return err
}
rows, err := tx.Query(`select id, lyrics_old FROM media_file WHERE lyrics_old <> '';`)
rows, err := tx.QueryContext(ctx, `select id, lyrics_old FROM media_file WHERE lyrics_old <> '';`)
if err != nil {
return err
}
@ -72,7 +72,7 @@ func upAlterLyricColumn(ctx context.Context, tx *sql.Tx) error {
return err
}
notice(tx, "A full rescan should be performed to pick up additional lyrics (existing lyrics have been preserved)")
notice(ctx, tx, "A full rescan should be performed to pick up additional lyrics (existing lyrics have been preserved)")
return nil
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -43,6 +43,6 @@ update library set
return err
}
func downAddLibraryStats(ctx context.Context, tx *sql.Tx) error {
func downAddLibraryStats(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -39,7 +39,7 @@ ALTER TABLE media_file RENAME COLUMN rg_track_peak_new TO rg_track_peak;
return err
}
notice(tx, "Fetching replaygain fields properly will require a full scan")
notice(ctx, tx, "Fetching replaygain fields properly will require a full scan")
return nil
}

View File

@ -22,7 +22,7 @@ func stripPunct(col string) string {
}
func upAddFts5Search(ctx context.Context, tx *sql.Tx) error {
notice(tx, "Adding FTS5 full-text search indexes. This may take a moment on large libraries.")
notice(ctx, tx, "Adding FTS5 full-text search indexes. This may take a moment on large libraries.")
// Step 1: Add search_participants and search_normalized columns to media_file, album, and artist
_, err := tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN search_participants TEXT NOT NULL DEFAULT ''`)

View File

@ -12,20 +12,20 @@ func init() {
goose.AddMigrationContext(upAddCodecAndUpdateTranscodings, downAddCodecAndUpdateTranscodings)
}
func upAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error {
func upAddCodecAndUpdateTranscodings(ctx context.Context, tx *sql.Tx) error {
// Add codec column to media_file.
_, err := tx.Exec(`ALTER TABLE media_file ADD COLUMN codec VARCHAR(255) DEFAULT '' NOT NULL`)
_, err := tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN codec VARCHAR(255) DEFAULT '' NOT NULL`)
if err != nil {
return err
}
_, err = tx.Exec(`CREATE INDEX IF NOT EXISTS media_file_codec ON media_file(codec)`)
_, err = tx.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS media_file_codec ON media_file(codec)`)
if err != nil {
return err
}
// Update old AAC default (adts) to new default (ipod with fragmented MP4).
// Only affects users who still have the unmodified old default command.
_, err = tx.Exec(
_, err = tx.ExecContext(ctx,
`UPDATE transcoding SET command = ? WHERE target_format = 'aac' AND command = ?`,
"ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -",
"ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
@ -36,12 +36,12 @@ func upAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error {
// Add FLAC transcoding for existing installations that were seeded before FLAC was added.
var count int
err = tx.QueryRow("SELECT COUNT(*) FROM transcoding WHERE target_format = 'flac'").Scan(&count)
err = tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM transcoding WHERE target_format = 'flac'").Scan(&count)
if err != nil {
return err
}
if count == 0 {
_, err = tx.Exec(
_, err = tx.ExecContext(ctx,
"INSERT INTO transcoding (id, name, target_format, default_bit_rate, command) VALUES (?, ?, ?, ?, ?)",
id.NewRandom(), "flac audio", "flac", 0,
"ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -",
@ -52,22 +52,22 @@ func upAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error {
}
// Add probe_data column for caching ffprobe results.
_, err = tx.Exec(`ALTER TABLE media_file ADD COLUMN probe_data TEXT DEFAULT NULL`)
_, err = tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN probe_data TEXT DEFAULT NULL`)
if err != nil {
return err
}
return nil
}
func downAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`ALTER TABLE media_file DROP COLUMN probe_data`)
func downAddCodecAndUpdateTranscodings(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `ALTER TABLE media_file DROP COLUMN probe_data`)
if err != nil {
return err
}
_, err = tx.Exec(`DROP INDEX IF EXISTS media_file_codec`)
_, err = tx.ExecContext(ctx, `DROP INDEX IF EXISTS media_file_codec`)
if err != nil {
return err
}
_, err = tx.Exec(`ALTER TABLE media_file DROP COLUMN codec`)
_, err = tx.ExecContext(ctx, `ALTER TABLE media_file DROP COLUMN codec`)
return err
}

View File

@ -11,18 +11,18 @@ func init() {
goose.AddMigrationContext(upFixProbeDataNull, downFixProbeDataNull)
}
func upFixProbeDataNull(_ context.Context, tx *sql.Tx) error {
func upFixProbeDataNull(ctx context.Context, tx *sql.Tx) error {
// Recreate probe_data column as NOT NULL with empty string default.
// The previous migration created it with DEFAULT NULL, which causes
// scan errors when reading into Go string fields.
_, err := tx.Exec(`ALTER TABLE media_file DROP COLUMN probe_data`)
_, err := tx.ExecContext(ctx, `ALTER TABLE media_file DROP COLUMN probe_data`)
if err != nil {
return err
}
_, err = tx.Exec(`ALTER TABLE media_file ADD COLUMN probe_data TEXT DEFAULT '' NOT NULL`)
_, err = tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN probe_data TEXT DEFAULT '' NOT NULL`)
return err
}
func downFixProbeDataNull(_ context.Context, tx *sql.Tx) error {
func downFixProbeDataNull(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -13,7 +13,7 @@ func init() {
goose.AddMigrationContext(upEnsureDefaultTranscodings, downEnsureDefaultTranscodings)
}
func upEnsureDefaultTranscodings(_ context.Context, tx *sql.Tx) error {
func upEnsureDefaultTranscodings(ctx context.Context, tx *sql.Tx) error {
// Older installations may be missing default transcodings that were added
// after the initial seeding (e.g., aac was added later than mp3/opus).
// Insert any missing defaults without touching user-customized entries.
@ -22,12 +22,12 @@ func upEnsureDefaultTranscodings(_ context.Context, tx *sql.Tx) error {
// but the same name.
for _, t := range consts.DefaultTranscodings {
var count int
err := tx.QueryRow("SELECT COUNT(*) FROM transcoding WHERE target_format = ? OR name = ?", t.TargetFormat, t.Name).Scan(&count)
err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM transcoding WHERE target_format = ? OR name = ?", t.TargetFormat, t.Name).Scan(&count)
if err != nil {
return err
}
if count == 0 {
_, err = tx.Exec(
_, err = tx.ExecContext(ctx,
"INSERT INTO transcoding (id, name, target_format, default_bit_rate, command) VALUES (?, ?, ?, ?, ?)",
id.NewRandom(), t.Name, t.TargetFormat, t.DefaultBitRate, t.Command,
)
@ -39,6 +39,6 @@ func upEnsureDefaultTranscodings(_ context.Context, tx *sql.Tx) error {
return nil
}
func downEnsureDefaultTranscodings(_ context.Context, tx *sql.Tx) error {
func downEnsureDefaultTranscodings(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -11,20 +11,20 @@ func init() {
goose.AddMigrationContext(upFixAacTranscodeCommand, downFixAacTranscodeCommand)
}
func upFixAacTranscodeCommand(_ context.Context, tx *sql.Tx) error {
func upFixAacTranscodeCommand(ctx context.Context, tx *sql.Tx) error {
// The old AAC command used `-f ipod -movflags frag_keyframe+empty_moov` which produces
// corrupt/silent audio when ffmpeg pipes to stdout (confirmed in ffmpeg 8.0+).
// Switch to `-f adts` (raw AAC framing) which works reliably via pipe.
// Only update rows that still have the old default command.
const oldCommand = "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -"
const newCommand = "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -"
_, err := tx.Exec(
_, err := tx.ExecContext(ctx,
"UPDATE transcoding SET command = ? WHERE target_format = 'aac' AND command = ?",
newCommand, oldCommand,
)
return err
}
func downFixAacTranscodeCommand(_ context.Context, tx *sql.Tx) error {
func downFixAacTranscodeCommand(_ context.Context, _ *sql.Tx) error {
return nil
}

View File

@ -36,18 +36,18 @@ var ssSeekPairs = [][2]string{
},
}
func upMoveSsBeforeInput(_ context.Context, tx *sql.Tx) error {
func upMoveSsBeforeInput(ctx context.Context, tx *sql.Tx) error {
for _, p := range ssSeekPairs {
if _, err := tx.Exec(`UPDATE transcoding SET command = ? WHERE command = ?`, p[1], p[0]); err != nil {
if _, err := tx.ExecContext(ctx, `UPDATE transcoding SET command = ? WHERE command = ?`, p[1], p[0]); err != nil {
return err
}
}
return nil
}
func downMoveSsBeforeInput(_ context.Context, tx *sql.Tx) error {
func downMoveSsBeforeInput(ctx context.Context, tx *sql.Tx) error {
for _, p := range ssSeekPairs {
if _, err := tx.Exec(`UPDATE transcoding SET command = ? WHERE command = ?`, p[0], p[1]); err != nil {
if _, err := tx.ExecContext(ctx, `UPDATE transcoding SET command = ? WHERE command = ?`, p[0], p[1]); err != nil {
return err
}
}

View File

@ -0,0 +1,64 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upAddMetadataToDefaultTranscodings, downAddMetadataToDefaultTranscodings)
}
// metadataPairs maps the current default commands (no metadata mapping) to the
// new defaults that preserve source tags. Index 0 = old, index 1 = new.
//
// The new commands add `-map_metadata 0 -map_metadata 0:s:a:0` after `-map 0:a:0`:
// `-map_metadata 0` copies format-level tags (MP3/FLAC sources) and
// `-map_metadata 0:s:a:0` copies tags from the first audio stream (OPUS/OGG
// sources); both are needed because the two source families store tags at
// different levels. Targeting the audio stream explicitly avoids pulling
// metadata from an embedded cover-art/video stream at index 0.
//
// AAC is included for consistency, but its `-f adts` container cannot hold tags,
// so the flags are a no-op there.
//
// Only rows still holding the exact unmodified default are updated, so any
// user-customized command is left untouched.
var metadataPairs = [][2]string{
{
"ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f 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 -",
},
{
"ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f 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 -",
},
{
"ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -",
"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 -",
},
{
"ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f 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 -",
},
}
func upAddMetadataToDefaultTranscodings(ctx context.Context, tx *sql.Tx) error {
for _, p := range metadataPairs {
if _, err := tx.ExecContext(ctx, `UPDATE transcoding SET command = ? WHERE command = ?`, p[1], p[0]); err != nil {
return err
}
}
return nil
}
func downAddMetadataToDefaultTranscodings(ctx context.Context, tx *sql.Tx) error {
for _, p := range metadataPairs {
if _, err := tx.ExecContext(ctx, `UPDATE transcoding SET command = ? WHERE command = ?`, p[0], p[1]); err != nil {
return err
}
}
return nil
}

View File

@ -12,23 +12,23 @@ import (
)
// Use this in migrations that need to communicate something important (breaking changes, forced reindexes, etc...)
func notice(tx *sql.Tx, msg string) {
if isDBInitialized(tx) {
func notice(ctx context.Context, tx *sql.Tx, msg string) {
if isDBInitialized(ctx, tx) {
line := strings.Repeat("*", len(msg)+8)
fmt.Printf("\n%s\nNOTICE: %s\n%s\n\n", line, msg, line)
}
}
// Call this in migrations that requires a full rescan
func forceFullRescan(tx *sql.Tx) error {
func forceFullRescan(ctx context.Context, tx *sql.Tx) error {
// If a full scan is required, most probably the query optimizer is outdated, so we run `analyze`.
if conf.Server.DevOptimizeDB {
_, err := tx.Exec(`ANALYZE;`)
_, err := tx.ExecContext(ctx, `ANALYZE;`)
if err != nil {
return err
}
}
_, err := tx.Exec(fmt.Sprintf(`
_, err := tx.ExecContext(ctx, fmt.Sprintf(`
INSERT OR REPLACE into property (id, value) values ('%s', '1');
`, consts.FullScanAfterMigrationFlagKey))
return err
@ -44,9 +44,9 @@ var (
initialized bool
)
func isDBInitialized(tx *sql.Tx) bool {
func isDBInitialized(ctx context.Context, tx *sql.Tx) bool {
once.Do(func() {
rows, err := tx.Query("select count(*) from property where id=?", consts.InitialSetupFlagKey)
rows, err := tx.QueryContext(ctx, "select count(*) from property where id=?", consts.InitialSetupFlagKey)
checkErr(err)
initialized = checkCount(rows) > 0
})

View File

@ -353,6 +353,19 @@ func (r *artistRepository) purgeEmpty() error {
return nil
}
// markOrphansMissing flags as missing any non-missing artist with no library_artist row, keeping the
// search fast-path's `missing = false` filter correct (see searchCfg). Called wherever such a row can
// be dropped: RefreshStats cleanup and library deletion cascade.
func (r *artistRepository) markOrphansMissing() error {
_, err := r.executeSQL(Expr(
"update artist set missing = true where missing = false " +
"and not exists (select 1 from library_artist where library_artist.artist_id = artist.id)"))
if err != nil {
return fmt.Errorf("marking orphaned artists missing: %w", err)
}
return nil
}
// markMissing marks artists as missing if all their albums are missing.
func (r *artistRepository) markMissing() error {
q := Expr(`
@ -527,57 +540,156 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) {
totalRowsAffected += rowsAffected
}
// // Remove library_artist entries for artists that no longer have any content in any library
// Remove library_artist entries for artists that no longer have any content in a library.
cleanupSQL := Delete("library_artist").Where("stats = '{}'")
cleanupRows, err := r.executeSQL(cleanupSQL)
if err != nil {
log.Warn(r.ctx, "Failed to cleanup empty library_artist entries", "error", err)
} else if cleanupRows > 0 {
log.Debug(r.ctx, "Cleaned up empty library_artist entries", "rowsDeleted", cleanupRows)
log.Warn(r.ctx, "Failed to cleanup empty library_artist entries", err)
} else {
if cleanupRows > 0 {
log.Debug(r.ctx, "Cleaned up empty library_artist entries", "rowsDeleted", cleanupRows)
}
// Reconcile orphans whenever the cleanup removed rows, and on a full refresh so a full scan
// also heals any left by older versions.
if cleanupRows > 0 || allArtists {
if err := r.markOrphansMissing(); err != nil {
log.Warn(r.ctx, "Failed to mark orphaned artists missing after library_artist cleanup", err)
}
}
}
log.Debug(r.ctx, "RefreshStats: Successfully updated stats.", "totalArtistsProcessed", len(allTouchedArtistIDs), "totalDBRowsAffected", totalRowsAffected)
return totalRowsAffected, nil
}
// applyLibraryFilterToSearchQuery is applyLibraryFilterToArtistQuery with the join order
// pinned via CROSS JOIN (SQLite's explicit join-order override): the search Phase 1 paginates
// rowids by artist.id, and when the planner drives from library_artist it must sort every
// junction row on every page (temp b-tree over the whole table). Keeping artist as the outer
// table streams rows in artist.id order from its primary key index, so LIMIT/OFFSET
// short-circuits. Search-only: other artist queries keep the planner's freedom.
func (r *artistRepository) applyLibraryFilterToSearchQuery(query SelectBuilder) SelectBuilder {
user := loggedUser(r.ctx)
query = query.CrossJoin("library_artist on library_artist.artist_id = artist.id")
if user.ID != invalidUserId && !user.IsAdmin {
query = query.Join("user_library on user_library.library_id = library_artist.library_id AND user_library.user_id = ?", user.ID)
}
return query
}
func (r *artistRepository) searchCfg() searchConfig {
// searchCfg builds the per-search config. scope is the set of library IDs the rowid Phase 1 must
// restrict artists to, or nil to skip the filter (fast-path). See [artistRepository.searchScope].
func (r *artistRepository) searchCfg(scope []int) searchConfig {
return searchConfig{
// Natural order for artists is more performant by ID, due to GROUP BY clause in selectArtist
NaturalOrder: "artist.id",
OrderBy: []string{"sum(json_extract(stats, '$.total.m')) desc", "name"},
MBIDFields: []string{"mbz_artist_id"},
LibraryFilter: r.applyLibraryFilterToSearchQuery,
NaturalOrder: "artist.id",
OrderBy: []string{"sum(json_extract(stats, '$.total.m')) desc", "name"},
MBIDFields: []string{"mbz_artist_id"},
// scope==nil is the fast-path: no filter (and orphans must not exist — see markOrphansMissing).
// Otherwise the join-free [artistLibraryFilter].
LibraryFilter: func(query SelectBuilder) SelectBuilder {
if scope == nil {
return query
}
return query.Where(artistLibraryFilter(scope))
},
}
}
// artistLibraryFilter restricts artists to the given libraries via a correlated EXISTS over the
// library_artist junction, staying join-free so it can scope the join-free search Phase 1 (a JOIN
// would fan out rowids and corrupt offset pagination). The inner LIMIT 1 is load-bearing: it stops
// SQLite from flattening the EXISTS back into a fan-out join, while still using the
// (library_id, artist_id) UNIQUE autoindex.
func artistLibraryFilter(libraryIDs []int) Sqlizer {
if len(libraryIDs) == 0 {
return Eq{"1": 2} // match nothing, without a degenerate `IN ()` subquery
}
sub, args, _ := Select("1").From("library_artist").
Where(And{
Expr("library_artist.artist_id = artist.id"),
Eq{"library_artist.library_id": libraryIDs},
}).Limit(1).ToSql()
return Expr("EXISTS ("+sub+")", args...)
}
func (r *artistRepository) Search(q string, options ...model.QueryOptions) (model.Artists, error) {
var opts model.QueryOptions
if len(options) > 0 {
opts = options[0]
}
// Artists have no library_id column, so the library_id filter callers pass (same as albums/songs)
// can't be applied directly: consume it and realize it as a join-free Phase-1 scope (searchCfg).
scope := r.searchScope(opts.Filters)
if isLibraryIDFilter(opts.Filters) {
opts.Filters = nil
}
var res dbArtists
err := r.doSearch(r.selectArtist(options...), q, &res, r.searchCfg(), opts)
err := r.doSearch(r.selectArtist(opts), q, &res, r.searchCfg(scope), opts)
if err != nil {
return nil, fmt.Errorf("searching artist %q: %w", q, err)
}
return res.toModels(), nil
}
// searchScope returns the library IDs the search must be restricted to, or nil to skip the filter
// entirely (the fast-path: the user sees everything the search could return, so a filter would be
// pure O(offset) overhead). It intersects the requested libraries with what the user can see.
func (r *artistRepository) searchScope(filter Sqlizer) []int {
visible, err := r.visibleLibraryIDs()
if err != nil {
return r.requestedLibraryIDs(filter) // fail safe: narrow to the request rather than widen
}
requested := r.requestedLibraryIDs(filter)
if requested == nil {
// No explicit request: scope to the visible set, unless the user sees everything.
if r.userSeesAllLibraries(visible) {
return nil
}
return visible
}
// Narrow unless the request already covers everything the user can see. Compare by membership,
// not length: the requested IDs may contain duplicates.
requestedSet := slice.ToSet(requested)
if slices.ContainsFunc(visible, func(id int) bool { _, ok := requestedSet[id]; return !ok }) {
return requested
}
return nil
}
// requestedLibraryIDs extracts the []int from an Eq{"library_id": ids} filter, or nil if filter is
// not that shape.
func (r *artistRepository) requestedLibraryIDs(filter Sqlizer) []int {
eq, ok := filter.(Eq)
if !ok {
return nil
}
ids, _ := eq["library_id"].([]int)
return ids
}
// isLibraryIDFilter reports whether the filter is an Eq carrying a library_id key, so Search can
// consume it before it reaches the bare artist table (which has no library_id column).
func isLibraryIDFilter(filter Sqlizer) bool {
eq, ok := filter.(Eq)
if !ok {
return false
}
_, ok = eq["library_id"]
return ok
}
// userSeesAllLibraries reports whether the visible set already covers every library, so a search
// needs no library filter at all.
func (r *artistRepository) userSeesAllLibraries(visible []int) bool {
user := loggedUser(r.ctx)
if user.IsAdmin || user.ID == invalidUserId {
return true // visible is the whole library table
}
total, err := NewLibraryRepository(r.ctx, r.db).CountAll()
if err != nil || total == 0 {
return false
}
return int64(len(visible)) >= total
}
// visibleLibraryIDs returns the libraries the current user can see: all libraries for admin and
// headless processes, otherwise the user's granted libraries.
func (r *artistRepository) visibleLibraryIDs() ([]int, error) {
user := loggedUser(r.ctx)
if user.IsAdmin || user.ID == invalidUserId {
var ids []int
err := r.queryAllSlice(Select("id").From("library"), &ids)
return ids, err
}
return slice.Map(user.Libraries, func(lib model.Library) int { return lib.ID }), nil
}
func (r *artistRepository) Count(options ...rest.QueryOptions) (int64, error) {
return r.CountAll(r.parseRestOptions(r.ctx, options...))
}

View File

@ -111,6 +111,80 @@ var _ = Describe("ArtistRepository", func() {
})
})
Describe("searchScope", func() {
// Resolves the library IDs a search must be restricted to (nil = fast-path / no filter),
// the way Search() does, for a repo whose context carries the given user.
scope := func(user model.User, filter squirrel.Sqlizer) []int {
ctx := request.WithUser(GinkgoT().Context(), user)
r := NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository)
return r.searchScope(filter)
}
subsetUser := model.User{ID: "u", Libraries: model.Libraries{{ID: 1}, {ID: 2}, {ID: 3}}}
It("scopes to a strict subset of the user's libraries", func() {
Expect(scope(subsetUser, squirrel.Eq{"library_id": []int{1, 2}})).To(Equal([]int{1, 2}))
})
It("treats duplicate IDs as a set so a real subset still narrows", func() {
// {1,1,2} has 3 entries but is a strict subset of the user's 3 libraries.
Expect(scope(subsetUser, squirrel.Eq{"library_id": []int{1, 1, 2}})).To(Equal([]int{1, 1, 2}))
})
It("returns nil (fast-path) when the request covers all the user's libraries", func() {
Expect(scope(subsetUser, squirrel.Eq{"library_id": []int{1, 2, 3}})).To(BeNil())
})
It("scopes to the user's libraries when no library filter is given", func() {
// A restricted user (strictly fewer libs than exist) with no musicFolderId is still
// confined to their granted libs. Build the user with total-1 libraries derived from
// the real DB total, so the "sees all" fast-path can't kick in regardless of count.
total, err := NewLibraryRepository(GinkgoT().Context(), GetDBXBuilder()).CountAll()
Expect(err).ToNot(HaveOccurred())
Expect(total).To(BeNumerically(">", 0))
libs := make(model.Libraries, 0, total-1)
for i := int64(1); i < total; i++ { // total-1 distinct libraries → a strict subset
libs = append(libs, model.Library{ID: int(i)})
}
restricted := model.User{ID: "r", Libraries: libs}
got := scope(restricted, nil)
Expect(got).To(HaveLen(int(total) - 1))
})
It("returns nil (fast-path) for an admin requesting all existing libraries", func() {
// Admins see every library, so the visible set is the whole library table — derive
// it from the DB rather than assuming a count.
var allLibs []int
Expect(NewLibraryRepository(GinkgoT().Context(), GetDBXBuilder()).(*libraryRepository).
queryAllSlice(squirrel.Select("id").From("library"), &allLibs)).To(Succeed())
admin := model.User{ID: "a", IsAdmin: true}
Expect(scope(admin, squirrel.Eq{"library_id": allLibs})).To(BeNil())
Expect(scope(admin, nil)).To(BeNil())
})
It("narrows for an admin explicitly requesting a subset via musicFolderId", func() {
// An admin scoping to a single, non-existent-as-the-whole-set library must still be
// narrowed (regression: search3?musicFolderId=lib2 was leaking lib1 content).
admin := model.User{ID: "a", IsAdmin: true}
Expect(scope(admin, squirrel.Eq{"library_id": []int{-1}})).To(Equal([]int{-1}))
})
It("returns nil for a non-library_id filter (no library scoping requested)", func() {
// Such a filter carries no library intent; for this fully-granted-style user the
// search needs no extra library restriction.
allUser := model.User{ID: "u2", IsAdmin: true}
Expect(scope(allUser, squirrel.Eq{"name": "x"})).To(BeNil())
})
It("falls back to the visible scope for a malformed library_id value (no crash)", func() {
// A library_id filter whose value isn't []int is still recognized as a library
// filter (so Search consumes it and it never reaches the bare artist table), and
// searchScope falls back to exactly the no-filter behavior rather than crashing.
malformed := squirrel.Eq{"library_id": "not-a-slice"}
Expect(isLibraryIDFilter(malformed)).To(BeTrue())
Expect(scope(subsetUser, malformed)).To(Equal(scope(subsetUser, nil)))
})
})
Describe("dbArtist mapping", func() {
var (
artist *model.Artist
@ -653,6 +727,38 @@ var _ = Describe("ArtistRepository", func() {
_, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": lib2Artist.ID}))
}
})
It("paginates a restricted user's visible artists without gaps", func() {
// ID "25" sorts between base fixtures "2" and "3", so this lib2-only artist lands
// inside the restricted user's visible range — exercising the no-gap guarantee.
lib2Artist := model.Artist{ID: "25", Name: "Restricted Lib2 Artist"}
Expect(repo.Put(&lib2Artist)).To(Succeed())
Expect(lr.AddArtist(lib2.ID, lib2Artist.ID)).To(Succeed())
DeferCleanup(func() {
if raw, ok := repo.(*artistRepository); ok {
_, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": lib2Artist.ID}))
}
})
all, err := restrictedRepo.Search("", model.QueryOptions{Max: 1000})
Expect(err).ToNot(HaveOccurred())
Expect(len(all)).To(BeNumerically(">", 1))
for _, a := range all {
Expect(a.ID).ToNot(Equal(lib2Artist.ID))
}
var paged model.Artists
for offset := range len(all) {
page, err := restrictedRepo.Search("", model.QueryOptions{Max: 1, Offset: offset})
Expect(err).ToNot(HaveOccurred())
Expect(page).To(HaveLen(1), fmt.Sprintf("page at offset %d should be full", offset))
paged = append(paged, page...)
}
Expect(paged).To(HaveLen(len(all)))
for i := range all {
Expect(paged[i].ID).To(Equal(all[i].ID))
}
})
})
Context("Headless Processes (No User Context)", func() {
@ -891,6 +997,45 @@ var _ = Describe("ArtistRepository", func() {
Expect(err).ToNot(HaveOccurred())
Expect(idx).To(HaveLen(0))
})
It("takes the unfiltered fast-path when the user can access every library", func() {
// The fixture DB has a single library and the user was granted it, so it has access
// to all libraries: search results must match what an admin sees.
adminRepo := NewArtistRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder())
adminAll, err := adminRepo.Search("", model.QueryOptions{Max: 1000})
Expect(err).ToNot(HaveOccurred())
userAll, err := restrictedRepo.Search("", model.QueryOptions{Max: 1000})
Expect(err).ToNot(HaveOccurred())
ids := func(artists model.Artists) []string {
out := make([]string, len(artists))
for i, a := range artists {
out[i] = a.ID
}
return out
}
Expect(ids(userAll)).To(Equal(ids(adminAll)))
Expect(userAll).ToNot(BeEmpty())
})
It("detects all-library access regardless of result equivalence", func() {
// userSeesAllLibraries drives the search fast-path for a non-admin: true when the
// visible-library count reaches the DB total. Derive the total from the DB so the
// assertion doesn't depend on how many libraries other specs left behind.
raw := restrictedRepo.(*artistRepository) // context carries a non-admin user
total, err := NewLibraryRepository(GinkgoT().Context(), GetDBXBuilder()).CountAll()
Expect(err).ToNot(HaveOccurred())
Expect(total).To(BeNumerically(">", 0))
allLibs := make([]int, total)
for i := range allLibs {
allLibs[i] = i + 1
}
Expect(raw.userSeesAllLibraries(allLibs)).To(BeTrue())
Expect(raw.userSeesAllLibraries(allLibs[:total-1])).To(BeFalse())
Expect(raw.userSeesAllLibraries([]int{})).To(BeFalse())
})
})
})
@ -976,6 +1121,66 @@ var _ = Describe("ArtistRepository", func() {
Expect(err).ToNot(HaveOccurred())
})
})
Describe("RefreshStats", func() {
var repo *artistRepository
missing := func(id string) bool {
var vals []bool
Expect(repo.queryAllSlice(squirrel.Select("missing").From("artist").Where(squirrel.Eq{"id": id}), &vals)).To(Succeed())
Expect(vals).To(HaveLen(1))
return vals[0]
}
BeforeEach(func() {
ctx := request.WithUser(GinkgoT().Context(), adminUser)
repo = NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository)
})
It("marks artists missing when the empty-stats cleanup drops their last library_artist row", func() {
// A library_artist row with stats '{}' (no content) gets deleted by the cleanup,
// which would orphan this non-missing artist.
emptyArtist := model.Artist{ID: "refresh-empty", Name: "No Content Artist"}
Expect(repo.Put(&emptyArtist)).To(Succeed())
_, err := repo.executeSQL(squirrel.Insert("library_artist").
SetMap(map[string]any{"library_id": 1, "artist_id": emptyArtist.ID, "stats": "{}"}))
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
_, _ = repo.executeSQL(squirrel.Delete("library_artist").Where(squirrel.Eq{"artist_id": emptyArtist.ID}))
_ = repo.delete(squirrel.Eq{"id": emptyArtist.ID})
})
Expect(missing(emptyArtist.ID)).To(BeFalse())
_, err = repo.RefreshStats(true)
Expect(err).ToNot(HaveOccurred())
Expect(missing(emptyArtist.ID)).To(BeTrue())
var orphanIDs []string
Expect(repo.queryAllSlice(squirrel.Select("id").From("artist").
Where("missing = false").
Where("id not in (select artist_id from library_artist)"), &orphanIDs)).To(Succeed())
Expect(orphanIDs).ToNot(ContainElement(emptyArtist.ID))
})
It("heals a pre-existing orphan (no library_artist row) on a full refresh", func() {
// A legacy orphan left by an older version: non-missing, with no library_artist row at
// all. The cleanup deletes nothing for it, so a full refresh (allArtists) must still
// reconcile it.
legacyOrphan := model.Artist{ID: "refresh-legacy-orphan", Name: "Legacy Orphan"}
Expect(repo.Put(&legacyOrphan)).To(Succeed())
DeferCleanup(func() {
_ = repo.delete(squirrel.Eq{"id": legacyOrphan.ID})
})
Expect(missing(legacyOrphan.ID)).To(BeFalse())
_, err := repo.RefreshStats(true)
Expect(err).ToNot(HaveOccurred())
Expect(missing(legacyOrphan.ID)).To(BeTrue())
})
})
})
// Helper function to create an artist with proper library association.

View File

@ -261,6 +261,11 @@ func (r *libraryRepository) Delete(id int) error {
return err
}
// The cascade above can drop an artist's last library_artist row; reconcile any such orphans.
if err := NewArtistRepository(r.ctx, r.db).(*artistRepository).markOrphansMissing(); err != nil {
return fmt.Errorf("marking orphaned artists missing after deleting library %d: %w", id, err)
}
// Clear cache entry for this library only if DB operation was successful
libLock.Lock()
defer libLock.Unlock()

View File

@ -4,6 +4,7 @@ import (
"context"
"time"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
@ -206,4 +207,49 @@ var _ = Describe("LibraryRepository", func() {
})
})
})
Describe("Delete", func() {
var adminRepo model.LibraryRepository
var artistRepo model.ArtistRepository
artistMissing := func(id string) bool {
var missing bool
err := conn.NewQuery("SELECT missing FROM artist WHERE id = {:id}").
Bind(dbx.Params{"id": id}).Row(&missing)
Expect(err).ToNot(HaveOccurred())
return missing
}
BeforeEach(func() {
adminCtx := request.WithUser(log.NewContext(context.TODO()), adminUser)
adminRepo = NewLibraryRepository(adminCtx, conn)
artistRepo = NewArtistRepository(adminCtx, conn)
})
It("marks artists orphaned by the delete as missing", func() {
lib := model.Library{Name: "Doomed Library", Path: "/doomed"}
Expect(adminRepo.Put(&lib)).To(Succeed())
orphanArtist := model.Artist{ID: "delete-orphan", Name: "Orphan To Be"}
sharedArtist := model.Artist{ID: "delete-shared", Name: "Shared Artist"}
Expect(artistRepo.Put(&orphanArtist)).To(Succeed())
Expect(artistRepo.Put(&sharedArtist)).To(Succeed())
Expect(adminRepo.AddArtist(lib.ID, orphanArtist.ID)).To(Succeed())
Expect(adminRepo.AddArtist(lib.ID, sharedArtist.ID)).To(Succeed())
Expect(adminRepo.AddArtist(1, sharedArtist.ID)).To(Succeed())
DeferCleanup(func() {
if raw, ok := artistRepo.(*artistRepository); ok {
_, _ = raw.executeSQL(squirrel.Delete("artist").
Where(squirrel.Eq{"id": []string{orphanArtist.ID, sharedArtist.ID}}))
}
})
Expect(artistMissing(orphanArtist.ID)).To(BeFalse())
Expect(adminRepo.Delete(lib.ID)).To(Succeed())
Expect(artistMissing(orphanArtist.ID)).To(BeTrue(), "orphaned artist should be marked missing")
Expect(artistMissing(sharedArtist.ID)).To(BeFalse(), "artist still in another library must stay visible")
})
})
})

View File

@ -21,10 +21,9 @@ type searchConfig struct {
NaturalOrder string // ORDER BY for empty-query results (e.g. "album.rowid")
OrderBy []string // ORDER BY for text search results (e.g. ["name"])
MBIDFields []string // columns to match when query is a UUID
// LibraryFilter overrides the default applyLibraryFilter for the rowid Phase 1 of
// two-phase searches (FTS and empty-query). Needed when library access goes through a
// junction table (e.g. artist → library_artist), whose JOIN can fan out rowids for
// entities in multiple libraries — Phase 1 dedups whenever this is set.
// LibraryFilter overrides the default applyLibraryFilter for the rowid Phase 1, for entities whose
// library access goes through a junction table (e.g. artist → library_artist). It MUST be join-free
// (Phase 1 has no DISTINCT, so a fan-out JOIN would corrupt offset pagination). See [artistLibraryFilter].
LibraryFilter func(sq SelectBuilder) SelectBuilder
}
@ -102,10 +101,7 @@ func (r sqlRepository) executeTwoPhase(sq SelectBuilder, results any, rowidCore
rowidQuery = rowidQuery.Offset(uint64(options.Offset))
}
if cfg.LibraryFilter != nil {
// Junction-table library filters can repeat rowids for entities in multiple
// libraries, which would corrupt offset-based pagination — dedup before paginating.
// (DISTINCT, not GROUP BY: bm25() can't be evaluated in a grouped query.)
rowidQuery = cfg.LibraryFilter(rowidQuery).Distinct()
rowidQuery = cfg.LibraryFilter(rowidQuery)
} else {
rowidQuery = r.applyLibraryFilter(rowidQuery)
}

View File

@ -2,6 +2,7 @@ package scanner_test
import (
"context"
"database/sql"
"errors"
"path/filepath"
"testing/fstest"
@ -531,6 +532,69 @@ var _ = Describe("Scanner", Ordered, func() {
})).To(Equal(int64(2)))
})
It("leaves no non-missing orphan artist after purging an artist's only content", func() {
// Guards the orphan case: with PurgeMissing on, removing an artist's last file hard-deletes
// its media_file_artists rows, RefreshStats recomputes its stats to '{}', and the cleanup
// drops its last library_artist row — leaving the artist row alive but orphaned. RefreshStats
// must then mark it missing (see markOrphansMissing).
DeferCleanup(configtest.SetupConfig())
conf.Server.Scanner.PurgeMissing = consts.PurgeMissingAlways
By("Starting from a library where Pink Floyd has its own single album")
floyd := template(_t{"artist": "Pink Floyd", "album": "The Wall", "year": 1979})
fsys = createFS(fstest.MapFS{
"The Beatles/Help!/01 - Help!.mp3": help(track(1, "Help!")),
"The Beatles/Help!/02 - The Night Before.mp3": help(track(2, "The Night Before")),
"The Beatles/Revolver/01 - Taxman.mp3": revolver(track(1, "Taxman")),
"The Beatles/Revolver/02 - Eleanor Rigby.mp3": revolver(track(2, "Eleanor Rigby")),
"Pink Floyd/The Wall/01 - Another Brick.mp3": floyd(track(1, "Another Brick in the Wall")),
})
Expect(runScanner(ctx, true)).To(Succeed())
nonMissingArtists := func() []string {
aa, err := ds.Artist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"missing": false}})
Expect(err).ToNot(HaveOccurred())
return slice.Map(aa, func(a model.Artist) string { return a.Name })
}
orphanCount := func() int64 {
var n int64
Expect(db.Db().QueryRowContext(ctx,
"SELECT count(*) FROM artist WHERE missing = false "+
"AND id NOT IN (SELECT artist_id FROM library_artist)").Scan(&n)).To(Succeed())
return n
}
// Read the artist row directly: selectArtist inner-joins library_artist, so an orphan never
// surfaces through the repository. Returns a descriptive string for clear test failures.
floydState := func() string {
var m bool
err := db.Db().QueryRowContext(ctx,
"SELECT missing FROM artist WHERE name = 'Pink Floyd'").Scan(&m)
if errors.Is(err, sql.ErrNoRows) {
return "NOT_FOUND"
}
Expect(err).ToNot(HaveOccurred())
if m {
return "MISSING"
}
return "PRESENT"
}
By("Confirming Pink Floyd is visible after the import, with no orphan")
Expect(nonMissingArtists()).To(ContainElement("Pink Floyd"))
Expect(floydState()).To(Equal("PRESENT"))
Expect(orphanCount()).To(BeZero())
By("Removing all of Pink Floyd's files and rescanning")
fsys.Remove("Pink Floyd/The Wall/01 - Another Brick.mp3")
Expect(runScanner(ctx, true)).To(Succeed())
By("Checking Pink Floyd's row survives but is marked missing, leaving no orphan")
Expect(floydState()).To(Equal("MISSING"))
Expect(orphanCount()).To(BeZero())
// The Beatles keep their content, so the fix must not over-mark them.
Expect(nonMissingArtists()).To(ContainElement("The Beatles"))
})
It("does not override artist fields when importing an undertagged file", func() {
By("Making sure artist in the DB contains MBID and sort name")
aa, err := ds.Artist(ctx).GetAll(model.QueryOptions{

View File

@ -147,12 +147,16 @@ func loadDir(ctx context.Context, job *scanJob, dirPath string, checker *IgnoreC
if fileInfo.ModTime().After(folder.modTime) {
folder.modTime = fileInfo.ModTime()
}
name, ok := resolveEntryName(ctx, job.fs, dirPath, entry)
if !ok {
continue
}
switch {
case model.IsAudioFile(entry.Name()):
case model.IsAudioFile(name):
folder.audioFiles[entry.Name()] = entry
case model.IsValidPlaylist(entry.Name()):
case model.IsValidPlaylist(name):
folder.numPlaylists++
case model.IsImageFile(entry.Name()):
case model.IsImageFile(name):
folder.imageFiles[entry.Name()] = entry
folder.imagesUpdatedAt = utils.TimeNewest(folder.imagesUpdatedAt, fileInfo.ModTime(), folder.modTime)
}
@ -213,6 +217,45 @@ func isDirOrSymlinkToDir(fsys fs.FS, baseDir string, dirEnt fs.DirEntry) (bool,
return fileInfo.IsDir(), nil
}
const maxSymlinkHops = 40
// resolveEntryName returns the name to classify the entry by, and whether to
// consider it at all. Symlinks are resolved to their final target so the caller
// classifies by the target's extension, not the link's name. Returns ok=false
// when symlinks are disabled or the target can't be resolved.
func resolveEntryName(ctx context.Context, fsys fs.FS, dirPath string, entry fs.DirEntry) (string, bool) {
if entry.Type()&fs.ModeSymlink == 0 {
return entry.Name(), true
}
linkPath := path.Join(dirPath, entry.Name())
if !conf.Server.Scanner.FollowSymlinks {
log.Trace(ctx, "Scanner: Skipping symlink, following is disabled", "path", linkPath)
return "", false
}
cur := linkPath
for hop := 0; hop < maxSymlinkHops; hop++ {
target, err := fs.ReadLink(fsys, cur)
if err != nil {
if hop == 0 {
log.Trace(ctx, "Scanner: Skipping symlink, cannot resolve target", "path", linkPath, err)
return "", false
}
resolved := path.Base(cur)
log.Trace(ctx, "Scanner: Resolved symlink", "path", linkPath, "target", cur, "name", resolved)
return resolved, true
}
if path.IsAbs(target) {
// Absolute targets are not valid fs.FS paths, so the next ReadLink fails and
// resolution stops here, leaving cur as the target to classify by name.
cur = target
} else {
cur = path.Join(path.Dir(cur), target)
}
}
log.Trace(ctx, "Scanner: Skipping symlink, too many hops (possible loop)", "path", linkPath)
return "", false
}
// isDirReadable returns true if the directory represented by dirEnt is readable
func isDirReadable(ctx context.Context, fsys fs.FS, dirPath string) bool {
dir, err := fsys.Open(dirPath)

View File

@ -45,6 +45,10 @@ var _ = Describe("walk_dir_tree", func() {
"root/d/f3.mp3": {},
"root/e/original/f1.mp3": {},
"root/e/symlink": {Mode: fs.ModeSymlink, Data: []byte("original")},
"root/f/realsong.mp3": {Data: []byte("AUDIO")},
"root/f/legit.mp3": {Mode: fs.ModeSymlink, Data: []byte("realsong.mp3")},
"root/f/secret": {Data: []byte("TOPSECRET")},
"root/f/evil.mp3": {Mode: fs.ModeSymlink, Data: []byte("secret")},
},
}
job = &scanJob{
@ -96,12 +100,18 @@ var _ = Describe("walk_dir_tree", func() {
// Symlink specific checks
if followSymlinks {
Expect(folders["root/e/symlink"].audioFiles).To(HaveLen(1))
Expect(folders["root/f"].audioFiles).To(HaveKey("legit.mp3"))
Expect(folders["root/f"].audioFiles).To(HaveKey("realsong.mp3"))
Expect(folders["root/f"].audioFiles).ToNot(HaveKey("evil.mp3"))
} else {
Expect(folders).ToNot(HaveKey("root/e/symlink"))
Expect(folders["root/f"].audioFiles).To(HaveKey("realsong.mp3"))
Expect(folders["root/f"].audioFiles).ToNot(HaveKey("legit.mp3"))
Expect(folders["root/f"].audioFiles).ToNot(HaveKey("evil.mp3"))
}
},
Entry("with symlinks enabled", true, 7),
Entry("with symlinks disabled", false, 6),
Entry("with symlinks enabled", true, 8),
Entry("with symlinks disabled", false, 7),
)
})
@ -264,6 +274,176 @@ var _ = Describe("walk_dir_tree", func() {
})
})
Describe("resolveEntryName", func() {
var fsys fs.FS
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
fsys = fstest.MapFS{
"dir/real.mp3": {Data: []byte("AUDIO")},
"dir/mid.mp3": {Mode: fs.ModeSymlink, Data: []byte("real.mp3")},
"dir/chain.mp3": {Mode: fs.ModeSymlink, Data: []byte("mid.mp3")},
"dir/audio.mp3": {Mode: fs.ModeSymlink, Data: []byte("real.mp3")},
"dir/evil.mp3": {Mode: fs.ModeSymlink, Data: []byte("../outside/passwd")},
"dir/loop1.mp3": {Mode: fs.ModeSymlink, Data: []byte("loop2.mp3")},
"dir/loop2.mp3": {Mode: fs.ModeSymlink, Data: []byte("loop1.mp3")},
"dir/dangle.mp3": {Mode: fs.ModeSymlink, Data: []byte("missing.mp3")},
}
})
resolve := func(name string) (string, bool) {
entries, err := fs.ReadDir(fsys, "dir")
Expect(err).ToNot(HaveOccurred())
for _, e := range entries {
if e.Name() == name {
return resolveEntryName(GinkgoT().Context(), fsys, "dir", e)
}
}
Fail("entry not found: " + name)
return "", false
}
Context("with symlinks enabled", func() {
BeforeEach(func() { conf.Server.Scanner.FollowSymlinks = true })
It("returns the entry name for a plain file", func() {
name, ok := resolve("real.mp3")
Expect(ok).To(BeTrue())
Expect(name).To(Equal("real.mp3"))
})
It("resolves a direct symlink to its audio target name", func() {
name, ok := resolve("audio.mp3")
Expect(ok).To(BeTrue())
Expect(name).To(Equal("real.mp3"))
})
It("resolves a symlink CHAIN to the final target name", func() {
name, ok := resolve("chain.mp3")
Expect(ok).To(BeTrue())
Expect(name).To(Equal("real.mp3"))
})
It("resolves a symlink to a non-audio target name (so caller can reject it)", func() {
name, ok := resolve("evil.mp3")
Expect(ok).To(BeTrue())
Expect(name).To(Equal("passwd"))
})
It("rejects a symlink loop", func() {
_, ok := resolve("loop1.mp3")
Expect(ok).To(BeFalse())
})
})
Context("with symlinks disabled", func() {
BeforeEach(func() { conf.Server.Scanner.FollowSymlinks = false })
It("returns the entry name for a plain file", func() {
name, ok := resolve("real.mp3")
Expect(ok).To(BeTrue())
Expect(name).To(Equal("real.mp3"))
})
It("skips any file symlink", func() {
_, ok := resolve("audio.mp3")
Expect(ok).To(BeFalse())
})
})
})
Describe("symlink chain (real fs)", func() {
BeforeEach(func() {
tests.SkipOnWindows("symlink semantics")
DeferCleanup(configtest.SetupConfig())
})
classify := func(fsys fs.FS, dirPath, name string) (string, bool) {
entries, err := fs.ReadDir(fsys, dirPath)
Expect(err).ToNot(HaveOccurred())
for _, e := range entries {
if e.Name() == name {
return resolveEntryName(GinkgoT().Context(), fsys, dirPath, e)
}
}
Fail("entry not found: " + name)
return "", false
}
Context("committed 3-level fixtures", func() {
// tests.Init chdirs to the repo root, so the committed fixtures are at "tests/fixtures".
var fsys fs.FS
BeforeEach(func() {
conf.Server.Scanner.FollowSymlinks = true
wd, err := os.Getwd()
Expect(err).ToNot(HaveOccurred())
fsys = os.DirFS(wd)
})
It("keeps a 3-level chain that resolves to real audio", func() {
name, ok := classify(fsys, "tests/fixtures/symlink_chain", "level3.mp3")
Expect(ok).To(BeTrue())
Expect(name).To(Equal("test.mp3"))
Expect(model.IsAudioFile(name)).To(BeTrue())
})
It("rejects a 3-level chain that resolves to a non-audio file", func() {
name, ok := classify(fsys, "tests/fixtures/symlink_chain", "evil3.mp3")
Expect(ok).To(BeTrue())
Expect(name).To(Equal("index.html"))
Expect(model.IsAudioFile(name)).To(BeFalse())
})
It("skips the chain entirely when FollowSymlinks is disabled", func() {
conf.Server.Scanner.FollowSymlinks = false
_, ok := classify(fsys, "tests/fixtures/symlink_chain", "level3.mp3")
Expect(ok).To(BeFalse())
_, ok = classify(fsys, "tests/fixtures/symlink_chain", "evil3.mp3")
Expect(ok).To(BeFalse())
})
})
Context("out-of-tree escape (temp dir)", func() {
var root string
BeforeEach(func() {
conf.Server.Scanner.FollowSymlinks = true
root = GinkgoT().TempDir()
outside := GinkgoT().TempDir()
Expect(os.WriteFile(filepath.Join(outside, "passwd"), []byte("TOPSECRET"), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(outside, "real.flac"), []byte("AUDIO"), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(root, "song.mp3"), []byte("AUDIO"), 0600)).To(Succeed())
// evil.mp3 escapes to a non-audio target; legit.flac is a valid out-of-tree audio symlink.
Expect(os.Symlink(filepath.Join(outside, "passwd"), filepath.Join(root, "evil.mp3"))).To(Succeed())
Expect(os.Symlink(filepath.Join(outside, "real.flac"), filepath.Join(root, "legit.flac"))).To(Succeed())
})
It("rejects the absolute-path escape but keeps legit out-of-tree audio", func() {
fsys := os.DirFS(root)
name, ok := classify(fsys, ".", "song.mp3")
Expect(ok).To(BeTrue())
Expect(model.IsAudioFile(name)).To(BeTrue())
name, ok = classify(fsys, ".", "legit.flac")
Expect(ok).To(BeTrue())
Expect(model.IsAudioFile(name)).To(BeTrue())
name, ok = classify(fsys, ".", "evil.mp3")
Expect(ok).To(BeTrue())
Expect(model.IsAudioFile(name)).To(BeFalse())
})
It("skips all file symlinks when FollowSymlinks is disabled", func() {
conf.Server.Scanner.FollowSymlinks = false
fsys := os.DirFS(root)
entries, err := fs.ReadDir(fsys, ".")
Expect(err).ToNot(HaveOccurred())
for _, e := range entries {
_, ok := resolveEntryName(GinkgoT().Context(), fsys, ".", e)
if e.Type()&fs.ModeSymlink != 0 {
Expect(ok).To(BeFalse(), e.Name())
} else {
Expect(ok).To(BeTrue(), e.Name())
}
}
})
})
})
Describe("isDirIgnored", func() {
DescribeTable("returns expected result",
func(dirName string, expected bool) {
@ -414,3 +594,30 @@ func (m *mockMusicFS) ReadDir(name string) ([]fs.DirEntry, error) {
}
return nil, fmt.Errorf("not a directory")
}
// ReadLink returns the target of the named symbolic link (implements fs.ReadLinkFS).
func (m *mockMusicFS) ReadLink(name string) (string, error) {
mapFS := m.FS.(fstest.MapFS)
entry, ok := mapFS[name]
if !ok {
return "", &fs.PathError{Op: "readlink", Path: name, Err: fs.ErrNotExist}
}
if entry.Mode&fs.ModeSymlink == 0 {
return "", &fs.PathError{Op: "readlink", Path: name, Err: fmt.Errorf("not a symlink")}
}
return string(entry.Data), nil
}
// Lstat returns FileInfo for the named file without following symlinks (implements fs.ReadLinkFS).
func (m *mockMusicFS) Lstat(name string) (fs.FileInfo, error) {
mapFS := m.FS.(fstest.MapFS)
if _, ok := mapFS[name]; !ok {
return nil, &fs.PathError{Op: "lstat", Path: name, Err: fs.ErrNotExist}
}
f, err := m.FS.Open(name)
if err != nil {
return nil, err
}
defer f.Close()
return f.Stat()
}

View File

@ -74,7 +74,7 @@ func (api *Router) searchAll(ctx context.Context, sp *searchParams, musicFolderI
if len(musicFolderIds) > 0 {
songOpts.Filters = Eq{"library_id": musicFolderIds}
albumOpts.Filters = Eq{"library_id": musicFolderIds}
artistOpts.Filters = Eq{"library_artist.library_id": musicFolderIds}
artistOpts.Filters = Eq{"library_id": musicFolderIds}
}
// Run searches in parallel

View File

@ -39,12 +39,17 @@ var _ = Describe("Search", func() {
}
Describe("Search2", func() {
It("should accept musicFolderId parameter", func() {
It("scopes all entity types to the requested libraries", func() {
// The subsonic layer passes the same library_id filter to all three repos; the
// artist repository translates it to the join-free library_artist predicate itself.
r := newGetRequest("query=test", "musicFolderId=1")
ctx := request.WithUser(r.Context(), model.User{
ID: "user1",
UserName: "testuser",
Libraries: []model.Library{{ID: 1, Name: "Library 1"}},
ID: "user1",
UserName: "testuser",
Libraries: []model.Library{
{ID: 1, Name: "Library 1"},
{ID: 2, Name: "Library 2"},
},
})
r = r.WithContext(ctx)
@ -54,14 +59,13 @@ var _ = Describe("Search", func() {
Expect(resp).ToNot(BeNil())
Expect(resp.SearchResult2).ToNot(BeNil())
// Verify that library filter was applied to all repositories
assertQueryOptions(mockAlbumRepo.Options.Filters, "library_id IN (?)", 1)
assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?)", 1)
assertQueryOptions(mockMediaFileRepo.Options.Filters, "library_id IN (?)", 1)
assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?)", 1)
})
It("should return results from all accessible libraries when musicFolderId is not provided", func() {
r := newGetRequest("query=test")
It("applies no library filter when musicFolderId is not provided", func() {
r := newGetRequest("query=test") // no musicFolderId → all accessible libraries
ctx := request.WithUser(r.Context(), model.User{
ID: "user1",
UserName: "testuser",
@ -79,10 +83,9 @@ var _ = Describe("Search", func() {
Expect(resp).ToNot(BeNil())
Expect(resp.SearchResult2).ToNot(BeNil())
// Verify that library filter was applied to all repositories with all accessible libraries
assertQueryOptions(mockAlbumRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3)
assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3)
assertQueryOptions(mockMediaFileRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3)
assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3)
})
It("should return empty results when user has no accessible libraries", func() {
@ -122,12 +125,15 @@ var _ = Describe("Search", func() {
})
Describe("Search3", func() {
It("should accept musicFolderId parameter", func() {
It("scopes all entity types to the requested libraries", func() {
r := newGetRequest("query=test", "musicFolderId=1")
ctx := request.WithUser(r.Context(), model.User{
ID: "user1",
UserName: "testuser",
Libraries: []model.Library{{ID: 1, Name: "Library 1"}},
ID: "user1",
UserName: "testuser",
Libraries: []model.Library{
{ID: 1, Name: "Library 1"},
{ID: 2, Name: "Library 2"},
},
})
r = r.WithContext(ctx)
@ -137,14 +143,13 @@ var _ = Describe("Search", func() {
Expect(resp).ToNot(BeNil())
Expect(resp.SearchResult3).ToNot(BeNil())
// Verify that library filter was applied to all repositories
assertQueryOptions(mockAlbumRepo.Options.Filters, "library_id IN (?)", 1)
assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?)", 1)
assertQueryOptions(mockMediaFileRepo.Options.Filters, "library_id IN (?)", 1)
assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?)", 1)
})
It("should return results from all accessible libraries when musicFolderId is not provided", func() {
r := newGetRequest("query=test")
It("applies no library filter when musicFolderId is not provided", func() {
r := newGetRequest("query=test") // no musicFolderId → all accessible libraries
ctx := request.WithUser(r.Context(), model.User{
ID: "user1",
UserName: "testuser",
@ -162,10 +167,9 @@ var _ = Describe("Search", func() {
Expect(resp).ToNot(BeNil())
Expect(resp.SearchResult3).ToNot(BeNil())
// Verify that library filter was applied to all repositories with all accessible libraries
assertQueryOptions(mockAlbumRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3)
assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3)
assertQueryOptions(mockMediaFileRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3)
assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3)
})
It("should return empty results when user has no accessible libraries", func() {

1
tests/fixtures/symlink_chain/evil1.mp3 vendored Symbolic link
View File

@ -0,0 +1 @@
../index.html

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