mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
Merge branch 'master' into pr-5076
This commit is contained in:
commit
7889deb410
@ -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
|
||||
|
||||
@ -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 -",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@ -403,6 +403,14 @@ func buildDynamicArgs(opts TranscodeOptions) []string {
|
||||
args = append(args, "-i", opts.FilePath)
|
||||
args = append(args, "-map", "0:a:0")
|
||||
|
||||
// Preserve source tags. -map_metadata 0 copies format-level tags (MP3/FLAC);
|
||||
// -map_metadata 0:s:a:0 copies tags from the first audio stream (OPUS/OGG).
|
||||
// Both are needed because the two source families store tags at different
|
||||
// levels. Targeting the audio stream explicitly (s:a:0 rather than s:0) avoids
|
||||
// pulling metadata from an embedded cover-art/video stream at index 0. Note:
|
||||
// adts (AAC) output cannot hold tags, so these are a no-op there.
|
||||
args = append(args, "-map_metadata", "0", "-map_metadata", "0:s:a:0")
|
||||
|
||||
if codec, ok := formatCodecMap[opts.Format]; ok {
|
||||
args = append(args, "-c:a", codec)
|
||||
}
|
||||
|
||||
@ -82,16 +82,16 @@ var _ = Describe("ffmpeg", func() {
|
||||
|
||||
Describe("isDefaultCommand", func() {
|
||||
It("returns true for known default mp3 command", func() {
|
||||
Expect(isDefaultCommand("mp3", "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue())
|
||||
Expect(isDefaultCommand("mp3", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue())
|
||||
})
|
||||
It("returns true for known default opus command", func() {
|
||||
Expect(isDefaultCommand("opus", "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue())
|
||||
Expect(isDefaultCommand("opus", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue())
|
||||
})
|
||||
It("returns true for known default aac command", func() {
|
||||
Expect(isDefaultCommand("aac", "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -")).To(BeTrue())
|
||||
Expect(isDefaultCommand("aac", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a aac -f adts -")).To(BeTrue())
|
||||
})
|
||||
It("returns true for known default flac command", func() {
|
||||
Expect(isDefaultCommand("flac", "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue())
|
||||
Expect(isDefaultCommand("flac", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue())
|
||||
})
|
||||
It("returns false for a custom command", func() {
|
||||
Expect(isDefaultCommand("mp3", "ffmpeg -i %s -b:a %bk -custom-flag -f mp3 -")).To(BeFalse())
|
||||
@ -113,6 +113,7 @@ var _ = Describe("ffmpeg", func() {
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.flac",
|
||||
"-map", "0:a:0",
|
||||
"-map_metadata", "0", "-map_metadata", "0:s:a:0",
|
||||
"-c:a", "libmp3lame",
|
||||
"-b:a", "256k",
|
||||
"-ar", "48000",
|
||||
@ -132,6 +133,7 @@ var _ = Describe("ffmpeg", func() {
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.dsf",
|
||||
"-map", "0:a:0",
|
||||
"-map_metadata", "0", "-map_metadata", "0:s:a:0",
|
||||
"-c:a", "flac",
|
||||
"-ar", "48000",
|
||||
"-v", "0",
|
||||
@ -149,6 +151,7 @@ var _ = Describe("ffmpeg", func() {
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.flac",
|
||||
"-map", "0:a:0",
|
||||
"-map_metadata", "0", "-map_metadata", "0:s:a:0",
|
||||
"-c:a", "libopus",
|
||||
"-b:a", "128k",
|
||||
"-v", "0",
|
||||
@ -169,6 +172,7 @@ var _ = Describe("ffmpeg", func() {
|
||||
"-ss", "30",
|
||||
"-i", "/music/file.mp3",
|
||||
"-map", "0:a:0",
|
||||
"-map_metadata", "0", "-map_metadata", "0:s:a:0",
|
||||
"-c:a", "libmp3lame",
|
||||
"-b:a", "192k",
|
||||
"-v", "0",
|
||||
@ -186,6 +190,7 @@ var _ = Describe("ffmpeg", func() {
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.flac",
|
||||
"-map", "0:a:0",
|
||||
"-map_metadata", "0", "-map_metadata", "0:s:a:0",
|
||||
"-c:a", "aac",
|
||||
"-b:a", "256k",
|
||||
"-v", "0",
|
||||
@ -203,6 +208,7 @@ var _ = Describe("ffmpeg", func() {
|
||||
Expect(args).To(Equal([]string{
|
||||
"ffmpeg", "-i", "/music/file.dsf",
|
||||
"-map", "0:a:0",
|
||||
"-map_metadata", "0", "-map_metadata", "0:s:a:0",
|
||||
"-c:a", "flac",
|
||||
"-sample_fmt", "s32",
|
||||
"-v", "0",
|
||||
|
||||
@ -253,7 +253,11 @@ func (r *libraryRepositoryWrapper) Delete(id string) error {
|
||||
return r.mapError(err)
|
||||
}
|
||||
|
||||
err = r.LibraryRepository.Delete(libID)
|
||||
// Run the deletion in a transaction so the cascade delete and the orphaned-artist
|
||||
// reconciliation it triggers (see libraryRepository.Delete) commit atomically.
|
||||
err = r.ds.WithTx(func(tx model.DataStore) error {
|
||||
return tx.Library(r.ctx).Delete(libID)
|
||||
}, "delete library")
|
||||
if err != nil {
|
||||
return r.mapError(err)
|
||||
}
|
||||
|
||||
@ -12,9 +12,9 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200130083147, Down20200130083147)
|
||||
}
|
||||
|
||||
func Up20200130083147(_ context.Context, tx *sql.Tx) error {
|
||||
func Up20200130083147(ctx context.Context, tx *sql.Tx) error {
|
||||
log.Info("Creating DB Schema")
|
||||
_, err := tx.Exec(`
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table if not exists album
|
||||
(
|
||||
id varchar(255) not null
|
||||
@ -179,6 +179,6 @@ create table if not exists user
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200130083147(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20200130083147(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200131183653, Down20200131183653)
|
||||
}
|
||||
|
||||
func Up20200131183653(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20200131183653(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table search_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
@ -37,8 +37,8 @@ update annotation set item_type = 'media_file' where item_type = 'mediaFile';
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200131183653(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Down20200131183653(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table search_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200208222418, Down20200208222418)
|
||||
}
|
||||
|
||||
func Up20200208222418(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20200208222418(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
update annotation set play_count = 0 where play_count is null;
|
||||
update annotation set rating = 0 where rating is null;
|
||||
create table annotation_dg_tmp
|
||||
@ -51,6 +51,6 @@ create index annotation_starred
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200208222418(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20200208222418(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,9 +11,9 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200220143731, Down20200220143731)
|
||||
}
|
||||
|
||||
func Up20200220143731(_ context.Context, tx *sql.Tx) error {
|
||||
notice(tx, "This migration will force the next scan to be a full rescan!")
|
||||
_, err := tx.Exec(`
|
||||
func Up20200220143731(ctx context.Context, tx *sql.Tx) error {
|
||||
notice(ctx, tx, "This migration will force the next scan to be a full rescan!")
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table media_file_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
@ -125,6 +125,6 @@ update media_file set updated_at = '0001-01-01';
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200220143731(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20200220143731(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,11 +11,11 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200310171621, Down20200310171621)
|
||||
}
|
||||
|
||||
func Up20200310171621(_ context.Context, tx *sql.Tx) error {
|
||||
notice(tx, "A full rescan will be performed to enable search by Album Artist!")
|
||||
return forceFullRescan(tx)
|
||||
func Up20200310171621(ctx context.Context, tx *sql.Tx) error {
|
||||
notice(ctx, tx, "A full rescan will be performed to enable search by Album Artist!")
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func Down20200310171621(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20200310171621(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200310181627, Down20200310181627)
|
||||
}
|
||||
|
||||
func Up20200310181627(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20200310181627(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table transcoding
|
||||
(
|
||||
id varchar(255) not null primary key,
|
||||
@ -45,8 +45,8 @@ create table player
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200310181627(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Down20200310181627(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
drop table transcoding;
|
||||
drop table player;
|
||||
`)
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200319211049, Down20200319211049)
|
||||
}
|
||||
|
||||
func Up20200319211049(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20200319211049(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table media_file
|
||||
add full_text varchar(255) default '';
|
||||
create index if not exists media_file_full_text
|
||||
@ -33,10 +33,10 @@ drop table if exists search;
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan will be performed!")
|
||||
return forceFullRescan(tx)
|
||||
notice(ctx, tx, "A full rescan will be performed!")
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func Down20200319211049(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20200319211049(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200325185135, Down20200325185135)
|
||||
}
|
||||
|
||||
func Up20200325185135(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20200325185135(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table album
|
||||
add album_artist_id varchar(255) default '';
|
||||
create index album_artist_album_id
|
||||
@ -26,10 +26,10 @@ create index media_file_artist_album_id
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan will be performed!")
|
||||
return forceFullRescan(tx)
|
||||
notice(ctx, tx, "A full rescan will be performed!")
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func Down20200325185135(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20200325185135(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,11 +11,11 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200326090707, Down20200326090707)
|
||||
}
|
||||
|
||||
func Up20200326090707(_ context.Context, tx *sql.Tx) error {
|
||||
notice(tx, "A full rescan will be performed!")
|
||||
return forceFullRescan(tx)
|
||||
func Up20200326090707(ctx context.Context, tx *sql.Tx) error {
|
||||
notice(ctx, tx, "A full rescan will be performed!")
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func Down20200326090707(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20200326090707(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200327193744, Down20200327193744)
|
||||
}
|
||||
|
||||
func Up20200327193744(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20200327193744(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table album_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
@ -72,10 +72,10 @@ create index album_max_year
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan will be performed!")
|
||||
return forceFullRescan(tx)
|
||||
notice(ctx, tx, "A full rescan will be performed!")
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func Down20200327193744(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20200327193744(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200404214704, Down20200404214704)
|
||||
}
|
||||
|
||||
func Up20200404214704(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20200404214704(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create index if not exists media_file_year
|
||||
on media_file (year);
|
||||
|
||||
@ -25,6 +25,6 @@ create index if not exists media_file_track_number
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200404214704(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20200404214704(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,11 +11,11 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200409002249, Down20200409002249)
|
||||
}
|
||||
|
||||
func Up20200409002249(_ context.Context, tx *sql.Tx) error {
|
||||
notice(tx, "A full rescan will be performed to enable search by individual Artist in an Album!")
|
||||
return forceFullRescan(tx)
|
||||
func Up20200409002249(ctx context.Context, tx *sql.Tx) error {
|
||||
notice(ctx, tx, "A full rescan will be performed to enable search by individual Artist in an Album!")
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func Down20200409002249(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20200409002249(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200411164603, Down20200411164603)
|
||||
}
|
||||
|
||||
func Up20200411164603(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20200411164603(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table playlist
|
||||
add created_at datetime;
|
||||
alter table playlist
|
||||
@ -23,6 +23,6 @@ update playlist
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200411164603(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20200411164603(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,11 +11,11 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200418110522, Down20200418110522)
|
||||
}
|
||||
|
||||
func Up20200418110522(_ context.Context, tx *sql.Tx) error {
|
||||
notice(tx, "A full rescan will be performed to fix search Albums by year")
|
||||
return forceFullRescan(tx)
|
||||
func Up20200418110522(ctx context.Context, tx *sql.Tx) error {
|
||||
notice(ctx, tx, "A full rescan will be performed to fix search Albums by year")
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func Down20200418110522(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20200418110522(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,11 +11,11 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200419222708, Down20200419222708)
|
||||
}
|
||||
|
||||
func Up20200419222708(_ context.Context, tx *sql.Tx) error {
|
||||
notice(tx, "A full rescan will be performed to change the search behaviour")
|
||||
return forceFullRescan(tx)
|
||||
func Up20200419222708(ctx context.Context, tx *sql.Tx) error {
|
||||
notice(ctx, tx, "A full rescan will be performed to change the search behaviour")
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func Down20200419222708(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20200419222708(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200423204116, Down20200423204116)
|
||||
}
|
||||
|
||||
func Up20200423204116(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20200423204116(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table artist
|
||||
add order_artist_name varchar(255) collate nocase;
|
||||
alter table artist
|
||||
@ -57,10 +57,10 @@ create index if not exists media_file_order_artist_name
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan will be performed to change the search behaviour")
|
||||
return forceFullRescan(tx)
|
||||
notice(ctx, tx, "A full rescan will be performed to change the search behaviour")
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func Down20200423204116(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20200423204116(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,18 +11,18 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200508093059, Down20200508093059)
|
||||
}
|
||||
|
||||
func Up20200508093059(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20200508093059(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table artist
|
||||
add song_count integer default 0 not null;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan will be performed to calculate artists' song counts")
|
||||
return forceFullRescan(tx)
|
||||
notice(ctx, tx, "A full rescan will be performed to calculate artists' song counts")
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func Down20200508093059(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20200508093059(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,18 +11,18 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200512104202, Down20200512104202)
|
||||
}
|
||||
|
||||
func Up20200512104202(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20200512104202(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table media_file
|
||||
add disc_subtitle varchar(255);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan will be performed to import disc subtitles")
|
||||
return forceFullRescan(tx)
|
||||
notice(ctx, tx, "A full rescan will be performed to import disc subtitles")
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func Down20200512104202(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20200512104202(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -13,8 +13,8 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200516140647, Down20200516140647)
|
||||
}
|
||||
|
||||
func Up20200516140647(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20200516140647(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table if not exists playlist_tracks
|
||||
(
|
||||
id integer default 0 not null,
|
||||
@ -28,7 +28,7 @@ create unique index if not exists playlist_tracks_pos
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := tx.Query("select id, tracks from playlist")
|
||||
rows, err := tx.QueryContext(ctx, "select id, tracks from playlist")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -49,7 +49,7 @@ create unique index if not exists playlist_tracks_pos
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
create table playlist_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
@ -96,6 +96,6 @@ func Up20200516140647UpdatePlaylistTracks(tx *sql.Tx, id string, tracks string)
|
||||
return nil
|
||||
}
|
||||
|
||||
func Down20200516140647(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20200516140647(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,46 +11,46 @@ func init() {
|
||||
goose.AddMigrationContext(Up20200608153717, Down20200608153717)
|
||||
}
|
||||
|
||||
func Up20200608153717(_ context.Context, tx *sql.Tx) error {
|
||||
func Up20200608153717(ctx context.Context, tx *sql.Tx) error {
|
||||
// First delete dangling players
|
||||
_, err := tx.Exec(`
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
delete from player where user_name not in (select user_name from user)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Also delete dangling players
|
||||
_, err = tx.Exec(`
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
delete from playlist where owner not in (select user_name from user)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Also delete dangling playlist tracks
|
||||
_, err = tx.Exec(`
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
delete from playlist_tracks where playlist_id not in (select id from playlist)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add foreign key to player table
|
||||
err = updatePlayer_20200608153717(tx)
|
||||
err = updatePlayer_20200608153717(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add foreign key to playlist table
|
||||
err = updatePlaylist_20200608153717(tx)
|
||||
err = updatePlaylist_20200608153717(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add foreign keys to playlist_tracks table
|
||||
return updatePlaylistTracks_20200608153717(tx)
|
||||
return updatePlaylistTracks_20200608153717(ctx, tx)
|
||||
}
|
||||
|
||||
func updatePlayer_20200608153717(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func updatePlayer_20200608153717(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table player_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
@ -77,8 +77,8 @@ alter table player_dg_tmp rename to player;
|
||||
return err
|
||||
}
|
||||
|
||||
func updatePlaylist_20200608153717(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func updatePlaylist_20200608153717(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table playlist_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
@ -108,8 +108,8 @@ create index playlist_name
|
||||
return err
|
||||
}
|
||||
|
||||
func updatePlaylistTracks_20200608153717(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func updatePlaylistTracks_20200608153717(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table playlist_tracks_dg_tmp
|
||||
(
|
||||
id integer default 0 not null,
|
||||
@ -133,6 +133,6 @@ create unique index playlist_tracks_pos
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20200608153717(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20200608153717(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -13,8 +13,8 @@ func init() {
|
||||
goose.AddMigrationContext(upAddDefaultTranscodings, downAddDefaultTranscodings)
|
||||
}
|
||||
|
||||
func upAddDefaultTranscodings(_ context.Context, tx *sql.Tx) error {
|
||||
row := tx.QueryRow("SELECT COUNT(*) FROM transcoding")
|
||||
func upAddDefaultTranscodings(ctx context.Context, tx *sql.Tx) error {
|
||||
row := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM transcoding")
|
||||
var count int
|
||||
err := row.Scan(&count)
|
||||
if err != nil {
|
||||
@ -38,6 +38,6 @@ func upAddDefaultTranscodings(_ context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func downAddDefaultTranscodings(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddDefaultTranscodings(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upAddPlaylistPath, downAddPlaylistPath)
|
||||
}
|
||||
|
||||
func upAddPlaylistPath(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upAddPlaylistPath(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table playlist
|
||||
add path string default '' not null;
|
||||
|
||||
@ -23,6 +23,6 @@ alter table playlist
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddPlaylistPath(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddPlaylistPath(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upCreatePlayQueuesTable, downCreatePlayQueuesTable)
|
||||
}
|
||||
|
||||
func upCreatePlayQueuesTable(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upCreatePlayQueuesTable(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table playqueue
|
||||
(
|
||||
id varchar(255) not null primary key,
|
||||
@ -32,6 +32,6 @@ create table playqueue
|
||||
return err
|
||||
}
|
||||
|
||||
func downCreatePlayQueuesTable(_ context.Context, tx *sql.Tx) error {
|
||||
func downCreatePlayQueuesTable(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upCreateBookmarkTable, downCreateBookmarkTable)
|
||||
}
|
||||
|
||||
func upCreateBookmarkTable(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upCreateBookmarkTable(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table bookmark
|
||||
(
|
||||
user_id varchar(255) not null
|
||||
@ -49,6 +49,6 @@ alter table playqueue_dg_tmp rename to playqueue;
|
||||
return err
|
||||
}
|
||||
|
||||
func downCreateBookmarkTable(_ context.Context, tx *sql.Tx) error {
|
||||
func downCreateBookmarkTable(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upDropEmailUniqueConstraint, downDropEmailUniqueConstraint)
|
||||
}
|
||||
|
||||
func upDropEmailUniqueConstraint(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upDropEmailUniqueConstraint(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table user_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
@ -38,6 +38,6 @@ alter table user_dg_tmp rename to user;
|
||||
return err
|
||||
}
|
||||
|
||||
func downDropEmailUniqueConstraint(_ context.Context, tx *sql.Tx) error {
|
||||
func downDropEmailUniqueConstraint(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,14 +11,14 @@ func init() {
|
||||
goose.AddMigrationContext(Up20201003111749, Down20201003111749)
|
||||
}
|
||||
|
||||
func Up20201003111749(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20201003111749(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create index if not exists annotation_starred_at
|
||||
on annotation (starred_at);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20201003111749(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20201003111749(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(Up20201010162350, Down20201010162350)
|
||||
}
|
||||
|
||||
func Up20201010162350(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20201010162350(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table album
|
||||
add size integer default 0 not null;
|
||||
create index if not exists album_size
|
||||
@ -28,7 +28,7 @@ where id not null;`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20201010162350(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20201010162350(ctx context.Context, tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(Up20201012210022, Down20201012210022)
|
||||
}
|
||||
|
||||
func Up20201012210022(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20201012210022(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table artist
|
||||
add size integer default 0 not null;
|
||||
create index if not exists artist_size
|
||||
@ -40,6 +40,6 @@ update playlist set size = ifnull((
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20201012210022(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20201012210022(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(Up20201021085410, Down20201021085410)
|
||||
}
|
||||
|
||||
func Up20201021085410(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20201021085410(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table media_file
|
||||
add mbz_track_id varchar(255);
|
||||
alter table media_file
|
||||
@ -49,11 +49,11 @@ alter table artist
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan needs to be performed to import more tags")
|
||||
return forceFullRescan(tx)
|
||||
notice(ctx, tx, "A full rescan needs to be performed to import more tags")
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func Down20201021085410(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20201021085410(ctx context.Context, tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(Up20201021093209, Down20201021093209)
|
||||
}
|
||||
|
||||
func Up20201021093209(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20201021093209(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create index if not exists media_file_artist
|
||||
on media_file (artist);
|
||||
create index if not exists media_file_album_artist
|
||||
@ -23,6 +23,6 @@ create index if not exists media_file_mbz_track_id
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20201021093209(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20201021093209(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,14 +11,14 @@ func init() {
|
||||
goose.AddMigrationContext(Up20201021135455, Down20201021135455)
|
||||
}
|
||||
|
||||
func Up20201021135455(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20201021135455(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create index if not exists media_file_artist_id
|
||||
on media_file (artist_id);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20201021135455(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20201021135455(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upAddArtistImageUrl, downAddArtistImageUrl)
|
||||
}
|
||||
|
||||
func upAddArtistImageUrl(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upAddArtistImageUrl(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table artist
|
||||
add biography varchar(255) default '' not null;
|
||||
alter table artist
|
||||
@ -31,6 +31,6 @@ alter table artist
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddArtistImageUrl(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddArtistImageUrl(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(Up20201110205344, Down20201110205344)
|
||||
}
|
||||
|
||||
func Up20201110205344(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20201110205344(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table media_file
|
||||
add comment varchar;
|
||||
alter table media_file
|
||||
@ -24,10 +24,10 @@ alter table album
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan will be performed to import comments and lyrics")
|
||||
return forceFullRescan(tx)
|
||||
notice(ctx, tx, "A full rescan will be performed to import comments and lyrics")
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func Down20201110205344(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20201110205344(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,14 +11,14 @@ func init() {
|
||||
goose.AddMigrationContext(Up20201128100726, Down20201128100726)
|
||||
}
|
||||
|
||||
func Up20201128100726(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20201128100726(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table player
|
||||
add report_real_path bool default FALSE not null;
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20201128100726(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20201128100726(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -13,8 +13,8 @@ func init() {
|
||||
goose.AddMigrationContext(Up20201213124814, Down20201213124814)
|
||||
}
|
||||
|
||||
func Up20201213124814(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func Up20201213124814(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table album
|
||||
add all_artist_ids varchar;
|
||||
|
||||
@ -25,11 +25,11 @@ create index if not exists album_all_artist_ids
|
||||
return err
|
||||
}
|
||||
|
||||
return updateAlbums20201213124814(tx)
|
||||
return updateAlbums20201213124814(ctx, tx)
|
||||
}
|
||||
|
||||
func updateAlbums20201213124814(tx *sql.Tx) error {
|
||||
rows, err := tx.Query(`
|
||||
func updateAlbums20201213124814(ctx context.Context, tx *sql.Tx) error {
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
select a.id, a.name, a.artist_id, a.album_artist_id, group_concat(mf.artist_id, ' ')
|
||||
from album a left join media_file mf on a.id = mf.album_id group by a.id
|
||||
`)
|
||||
@ -59,6 +59,6 @@ select a.id, a.name, a.artist_id, a.album_artist_id, group_concat(mf.artist_id,
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func Down20201213124814(_ context.Context, tx *sql.Tx) error {
|
||||
func Down20201213124814(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upAddTimestampIndexesGo, downAddTimestampIndexesGo)
|
||||
}
|
||||
|
||||
func upAddTimestampIndexesGo(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upAddTimestampIndexesGo(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create index if not exists album_updated_at
|
||||
on album (updated_at);
|
||||
create index if not exists album_created_at
|
||||
@ -29,6 +29,6 @@ create index if not exists media_file_updated_at
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddTimestampIndexesGo(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddTimestampIndexesGo(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -14,10 +14,10 @@ func init() {
|
||||
goose.AddMigrationContext(upFixAlbumComments, downFixAlbumComments)
|
||||
}
|
||||
|
||||
func upFixAlbumComments(_ context.Context, tx *sql.Tx) error {
|
||||
func upFixAlbumComments(ctx context.Context, tx *sql.Tx) error {
|
||||
//nolint:gosec
|
||||
rows, err := tx.Query(`
|
||||
SELECT album.id, group_concat(media_file.comment, '` + consts.Zwsp + `') FROM album, media_file WHERE media_file.album_id = album.id GROUP BY album.id;
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
SELECT album.id, group_concat(media_file.comment, '`+consts.Zwsp+`') FROM album, media_file WHERE media_file.album_id = album.id GROUP BY album.id;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -49,7 +49,7 @@ func upFixAlbumComments(_ context.Context, tx *sql.Tx) error {
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func downFixAlbumComments(_ context.Context, tx *sql.Tx) error {
|
||||
func downFixAlbumComments(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upAddBpmMetadata, downAddBpmMetadata)
|
||||
}
|
||||
|
||||
func upAddBpmMetadata(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upAddBpmMetadata(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table media_file
|
||||
add bpm integer;
|
||||
|
||||
@ -22,10 +22,10 @@ create index if not exists media_file_bpm
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan needs to be performed to import more tags")
|
||||
return forceFullRescan(tx)
|
||||
notice(ctx, tx, "A full rescan needs to be performed to import more tags")
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func downAddBpmMetadata(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddBpmMetadata(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upCreateSharesTable, downCreateSharesTable)
|
||||
}
|
||||
|
||||
func upCreateSharesTable(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upCreateSharesTable(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table share
|
||||
(
|
||||
id varchar(255) not null primary key,
|
||||
@ -30,6 +30,6 @@ create table share
|
||||
return err
|
||||
}
|
||||
|
||||
func downCreateSharesTable(_ context.Context, tx *sql.Tx) error {
|
||||
func downCreateSharesTable(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upUpdateShareFieldNames, downUpdateShareFieldNames)
|
||||
}
|
||||
|
||||
func upUpdateShareFieldNames(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upUpdateShareFieldNames(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table share rename column expires to expires_at;
|
||||
alter table share rename column created to created_at;
|
||||
alter table share rename column last_visited to last_visited_at;
|
||||
@ -21,6 +21,6 @@ alter table share rename column last_visited to last_visited_at;
|
||||
return err
|
||||
}
|
||||
|
||||
func downUpdateShareFieldNames(_ context.Context, tx *sql.Tx) error {
|
||||
func downUpdateShareFieldNames(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -16,7 +16,7 @@ func init() {
|
||||
}
|
||||
|
||||
func upEncodeAllPasswords(ctx context.Context, tx *sql.Tx) error {
|
||||
rows, err := tx.Query(`SELECT id, user_name, password from user;`)
|
||||
rows, err := tx.QueryContext(ctx, `SELECT id, user_name, password from user;`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -51,6 +51,6 @@ func upEncodeAllPasswords(ctx context.Context, tx *sql.Tx) error {
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func downEncodeAllPasswords(_ context.Context, tx *sql.Tx) error {
|
||||
func downEncodeAllPasswords(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upDropPlayerNameUniqueConstraint, downDropPlayerNameUniqueConstraint)
|
||||
}
|
||||
|
||||
func upDropPlayerNameUniqueConstraint(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upDropPlayerNameUniqueConstraint(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table player_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
@ -43,6 +43,6 @@ create index if not exists player_name
|
||||
return err
|
||||
}
|
||||
|
||||
func downDropPlayerNameUniqueConstraint(_ context.Context, tx *sql.Tx) error {
|
||||
func downDropPlayerNameUniqueConstraint(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,16 +11,16 @@ func init() {
|
||||
goose.AddMigrationContext(upAddUserPrefsPlayerScrobblerEnabled, downAddUserPrefsPlayerScrobblerEnabled)
|
||||
}
|
||||
|
||||
func upAddUserPrefsPlayerScrobblerEnabled(_ context.Context, tx *sql.Tx) error {
|
||||
err := upAddUserPrefs(tx)
|
||||
func upAddUserPrefsPlayerScrobblerEnabled(ctx context.Context, tx *sql.Tx) error {
|
||||
err := upAddUserPrefs(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return upPlayerScrobblerEnabled(tx)
|
||||
return upPlayerScrobblerEnabled(ctx, tx)
|
||||
}
|
||||
|
||||
func upAddUserPrefs(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upAddUserPrefs(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table user_props
|
||||
(
|
||||
user_id varchar not null,
|
||||
@ -33,13 +33,13 @@ create table user_props
|
||||
return err
|
||||
}
|
||||
|
||||
func upPlayerScrobblerEnabled(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upPlayerScrobblerEnabled(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table player add scrobble_enabled bool default true;
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddUserPrefsPlayerScrobblerEnabled(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddUserPrefsPlayerScrobblerEnabled(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upAddReferentialIntegrityToUserProps, downAddReferentialIntegrityToUserProps)
|
||||
}
|
||||
|
||||
func upAddReferentialIntegrityToUserProps(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upAddReferentialIntegrityToUserProps(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table user_props_dg_tmp
|
||||
(
|
||||
user_id varchar not null
|
||||
@ -34,6 +34,6 @@ alter table user_props_dg_tmp rename to user_props;
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddReferentialIntegrityToUserProps(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddReferentialIntegrityToUserProps(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upAddScrobbleBuffer, downAddScrobbleBuffer)
|
||||
}
|
||||
|
||||
func upAddScrobbleBuffer(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upAddScrobbleBuffer(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table if not exists scrobble_buffer
|
||||
(
|
||||
user_id varchar not null
|
||||
@ -34,6 +34,6 @@ create table if not exists scrobble_buffer
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddScrobbleBuffer(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddScrobbleBuffer(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,9 +11,9 @@ func init() {
|
||||
goose.AddMigrationContext(upAddGenreTables, downAddGenreTables)
|
||||
}
|
||||
|
||||
func upAddGenreTables(_ context.Context, tx *sql.Tx) error {
|
||||
notice(tx, "A full rescan will be performed to import multiple genres!")
|
||||
_, err := tx.Exec(`
|
||||
func upAddGenreTables(ctx context.Context, tx *sql.Tx) error {
|
||||
notice(ctx, tx, "A full rescan will be performed to import multiple genres!")
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table if not exists genre
|
||||
(
|
||||
id varchar not null primary key,
|
||||
@ -61,9 +61,9 @@ create table if not exists artist_genres
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return forceFullRescan(tx)
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func downAddGenreTables(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddGenreTables(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upAddMediafileChannels, downAddMediafileChannels)
|
||||
}
|
||||
|
||||
func upAddMediafileChannels(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upAddMediafileChannels(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table media_file
|
||||
add channels integer;
|
||||
|
||||
@ -22,10 +22,10 @@ create index if not exists media_file_channels
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan needs to be performed to import more tags")
|
||||
return forceFullRescan(tx)
|
||||
notice(ctx, tx, "A full rescan needs to be performed to import more tags")
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func downAddMediafileChannels(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddMediafileChannels(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upAddSmartPlaylist, downAddSmartPlaylist)
|
||||
}
|
||||
|
||||
func upAddSmartPlaylist(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upAddSmartPlaylist(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table playlist
|
||||
add column rules varchar null;
|
||||
alter table playlist
|
||||
@ -33,6 +33,6 @@ create unique index playlist_fields_idx
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddSmartPlaylist(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddSmartPlaylist(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -14,8 +14,8 @@ func init() {
|
||||
goose.AddMigrationContext(upAddOrderTitleToMediaFile, downAddOrderTitleToMediaFile)
|
||||
}
|
||||
|
||||
func upAddOrderTitleToMediaFile(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upAddOrderTitleToMediaFile(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table main.media_file
|
||||
add order_title varchar null collate NOCASE;
|
||||
create index if not exists media_file_order_title
|
||||
@ -25,12 +25,12 @@ create index if not exists media_file_order_title
|
||||
return err
|
||||
}
|
||||
|
||||
return upAddOrderTitleToMediaFile_populateOrderTitle(tx)
|
||||
return upAddOrderTitleToMediaFile_populateOrderTitle(ctx, tx)
|
||||
}
|
||||
|
||||
//goland:noinspection GoSnakeCaseUsage
|
||||
func upAddOrderTitleToMediaFile_populateOrderTitle(tx *sql.Tx) error {
|
||||
rows, err := tx.Query(`select id, title from media_file`)
|
||||
func upAddOrderTitleToMediaFile_populateOrderTitle(ctx context.Context, tx *sql.Tx) error {
|
||||
rows, err := tx.QueryContext(ctx, `select id, title from media_file`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -57,6 +57,6 @@ func upAddOrderTitleToMediaFile_populateOrderTitle(tx *sql.Tx) error {
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func downAddOrderTitleToMediaFile(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddOrderTitleToMediaFile(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -13,8 +13,8 @@ func init() {
|
||||
goose.AddMigrationContext(upUnescapeLyricsAndComments, downUnescapeLyricsAndComments)
|
||||
}
|
||||
|
||||
func upUnescapeLyricsAndComments(_ context.Context, tx *sql.Tx) error {
|
||||
rows, err := tx.Query(`select id, comment, lyrics, title from media_file`)
|
||||
func upUnescapeLyricsAndComments(ctx context.Context, tx *sql.Tx) error {
|
||||
rows, err := tx.QueryContext(ctx, `select id, comment, lyrics, title from media_file`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -43,6 +43,6 @@ func upUnescapeLyricsAndComments(_ context.Context, tx *sql.Tx) error {
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func downUnescapeLyricsAndComments(_ context.Context, tx *sql.Tx) error {
|
||||
func downUnescapeLyricsAndComments(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upAddUseridToPlaylist, downAddUseridToPlaylist)
|
||||
}
|
||||
|
||||
func upAddUseridToPlaylist(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upAddUseridToPlaylist(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table playlist_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
@ -56,6 +56,6 @@ create index playlist_updated_at
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddUseridToPlaylist(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddUseridToPlaylist(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,14 +11,14 @@ func init() {
|
||||
goose.AddMigrationContext(upAddAlphabeticalByArtistIndex, downAddAlphabeticalByArtistIndex)
|
||||
}
|
||||
|
||||
func upAddAlphabeticalByArtistIndex(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upAddAlphabeticalByArtistIndex(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create index album_alphabetical_by_artist
|
||||
ON album(compilation, order_album_artist_name, order_album_name)
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddAlphabeticalByArtistIndex(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddAlphabeticalByArtistIndex(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,13 +11,13 @@ func init() {
|
||||
goose.AddMigrationContext(upRemoveInvalidArtistIds, downRemoveInvalidArtistIds)
|
||||
}
|
||||
|
||||
func upRemoveInvalidArtistIds(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upRemoveInvalidArtistIds(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
update media_file set artist_id = '' where not exists(select 1 from artist where id = artist_id)
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func downRemoveInvalidArtistIds(_ context.Context, tx *sql.Tx) error {
|
||||
func downRemoveInvalidArtistIds(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,19 +11,19 @@ func init() {
|
||||
goose.AddMigrationContext(upAddMusicbrainzReleaseTrackId, downAddMusicbrainzReleaseTrackId)
|
||||
}
|
||||
|
||||
func upAddMusicbrainzReleaseTrackId(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upAddMusicbrainzReleaseTrackId(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table media_file
|
||||
add mbz_release_track_id varchar(255);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan needs to be performed to import more tags")
|
||||
return forceFullRescan(tx)
|
||||
notice(ctx, tx, "A full rescan needs to be performed to import more tags")
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func downAddMusicbrainzReleaseTrackId(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddMusicbrainzReleaseTrackId(ctx context.Context, tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,17 +11,17 @@ func init() {
|
||||
goose.AddMigrationContext(upAddAlbumImagePaths, downAddAlbumImagePaths)
|
||||
}
|
||||
|
||||
func upAddAlbumImagePaths(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upAddAlbumImagePaths(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table main.album add image_files varchar;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan needs to be performed to import all album images")
|
||||
return forceFullRescan(tx)
|
||||
notice(ctx, tx, "A full rescan needs to be performed to import all album images")
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func downAddAlbumImagePaths(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddAlbumImagePaths(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,18 +11,18 @@ func init() {
|
||||
goose.AddMigrationContext(upRemoveCoverArtId, downRemoveCoverArtId)
|
||||
}
|
||||
|
||||
func upRemoveCoverArtId(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upRemoveCoverArtId(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table album drop column cover_art_id;
|
||||
alter table album rename column cover_art_path to embed_art_path
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice(tx, "A full rescan needs to be performed to import all album images")
|
||||
return forceFullRescan(tx)
|
||||
notice(ctx, tx, "A full rescan needs to be performed to import all album images")
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func downRemoveCoverArtId(_ context.Context, tx *sql.Tx) error {
|
||||
func downRemoveCoverArtId(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -16,15 +16,15 @@ func init() {
|
||||
goose.AddMigrationContext(upAddAlbumPaths, downAddAlbumPaths)
|
||||
}
|
||||
|
||||
func upAddAlbumPaths(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`alter table album add paths varchar;`)
|
||||
func upAddAlbumPaths(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `alter table album add paths varchar;`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
//nolint:gosec
|
||||
rows, err := tx.Query(`
|
||||
select album_id, group_concat(path, '` + consts.Zwsp + `') from media_file group by album_id
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
select album_id, group_concat(path, '`+consts.Zwsp+`') from media_file group by album_id
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -63,6 +63,6 @@ func upAddAlbumPathsDirs(filePaths string) string {
|
||||
return strings.Join(dirs, string(filepath.ListSeparator))
|
||||
}
|
||||
|
||||
func downAddAlbumPaths(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddAlbumPaths(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,11 +11,11 @@ func init() {
|
||||
goose.AddMigrationContext(upTouchPlaylists, downTouchPlaylists)
|
||||
}
|
||||
|
||||
func upTouchPlaylists(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`update playlist set updated_at = datetime('now');`)
|
||||
func upTouchPlaylists(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `update playlist set updated_at = datetime('now');`)
|
||||
return err
|
||||
}
|
||||
|
||||
func downTouchPlaylists(_ context.Context, tx *sql.Tx) error {
|
||||
func downTouchPlaylists(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upCreateInternetRadio, downCreateInternetRadio)
|
||||
}
|
||||
|
||||
func upCreateInternetRadio(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upCreateInternetRadio(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table if not exists radio
|
||||
(
|
||||
id varchar(255) not null primary key,
|
||||
@ -26,6 +26,6 @@ create table if not exists radio
|
||||
return err
|
||||
}
|
||||
|
||||
func downCreateInternetRadio(_ context.Context, tx *sql.Tx) error {
|
||||
func downCreateInternetRadio(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upAddReplaygainMetadata, downAddReplaygainMetadata)
|
||||
}
|
||||
|
||||
func upAddReplaygainMetadata(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upAddReplaygainMetadata(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table media_file add
|
||||
rg_album_gain real;
|
||||
alter table media_file add
|
||||
@ -26,10 +26,10 @@ alter table media_file add
|
||||
return err
|
||||
}
|
||||
|
||||
notice(tx, "A full rescan needs to be performed to import more tags")
|
||||
return forceFullRescan(tx)
|
||||
notice(ctx, tx, "A full rescan needs to be performed to import more tags")
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func downAddReplaygainMetadata(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddReplaygainMetadata(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upAddAlbumInfo, downAddAlbumInfo)
|
||||
}
|
||||
|
||||
func upAddAlbumInfo(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upAddAlbumInfo(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table album
|
||||
add description varchar(255) default '' not null;
|
||||
alter table album
|
||||
@ -29,6 +29,6 @@ alter table album
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddAlbumInfo(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddAlbumInfo(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upAddMissingShareInfo, downAddMissingShareInfo)
|
||||
}
|
||||
|
||||
func upAddMissingShareInfo(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upAddMissingShareInfo(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
drop table if exists share;
|
||||
create table share
|
||||
(
|
||||
@ -37,6 +37,6 @@ create table share
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddMissingShareInfo(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddMissingShareInfo(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -16,10 +16,10 @@ func init() {
|
||||
goose.AddMigrationContext(upChangePathListSeparator, downChangePathListSeparator)
|
||||
}
|
||||
|
||||
func upChangePathListSeparator(_ context.Context, tx *sql.Tx) error {
|
||||
func upChangePathListSeparator(ctx context.Context, tx *sql.Tx) error {
|
||||
//nolint:gosec
|
||||
rows, err := tx.Query(`
|
||||
select album_id, group_concat(path, '` + consts.Zwsp + `') from media_file group by album_id
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
select album_id, group_concat(path, '`+consts.Zwsp+`') from media_file group by album_id
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -58,6 +58,6 @@ func upChangePathListSeparatorDirs(filePaths string) string {
|
||||
return strings.Join(dirs, consts.Zwsp)
|
||||
}
|
||||
|
||||
func downChangePathListSeparator(_ context.Context, tx *sql.Tx) error {
|
||||
func downChangePathListSeparator(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -16,8 +16,8 @@ func init() {
|
||||
goose.AddMigrationContext(upChangeImageFilesListSeparator, downChangeImageFilesListSeparator)
|
||||
}
|
||||
|
||||
func upChangeImageFilesListSeparator(_ context.Context, tx *sql.Tx) error {
|
||||
rows, err := tx.Query(`select id, image_files from album`)
|
||||
func upChangeImageFilesListSeparator(ctx context.Context, tx *sql.Tx) error {
|
||||
rows, err := tx.QueryContext(ctx, `select id, image_files from album`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -54,7 +54,7 @@ func upChangeImageFilesListSeparatorDirs(filePaths string) string {
|
||||
return strings.Join(allPaths, consts.Zwsp)
|
||||
}
|
||||
|
||||
func downChangeImageFilesListSeparator(_ context.Context, tx *sql.Tx) error {
|
||||
func downChangeImageFilesListSeparator(ctx context.Context, tx *sql.Tx) error {
|
||||
// This code is executed when the migration is rolled back.
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,14 +11,14 @@ func init() {
|
||||
goose.AddMigrationContext(upAddDownloadToShare, downAddDownloadToShare)
|
||||
}
|
||||
|
||||
func upAddDownloadToShare(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upAddDownloadToShare(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table share
|
||||
add downloadable bool not null default false;
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddDownloadToShare(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddDownloadToShare(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ func init() {
|
||||
goose.AddMigrationContext(upAddRelRecYear, downAddRelRecYear)
|
||||
}
|
||||
|
||||
func upAddRelRecYear(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upAddRelRecYear(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table media_file
|
||||
add date varchar(255) default '' not null;
|
||||
alter table media_file
|
||||
@ -41,10 +41,10 @@ alter table album
|
||||
return err
|
||||
}
|
||||
|
||||
notice(tx, "A full rescan needs to be performed to import more tags")
|
||||
return forceFullRescan(tx)
|
||||
notice(ctx, tx, "A full rescan needs to be performed to import more tags")
|
||||
return forceFullRescan(ctx, tx)
|
||||
}
|
||||
|
||||
func downAddRelRecYear(_ context.Context, tx *sql.Tx) error {
|
||||
func downAddRelRecYear(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,16 +11,16 @@ func init() {
|
||||
goose.AddMigrationContext(upRenameMusicbrainzRecordingId, downRenameMusicbrainzRecordingId)
|
||||
}
|
||||
|
||||
func upRenameMusicbrainzRecordingId(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func upRenameMusicbrainzRecordingId(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table media_file
|
||||
rename column mbz_track_id to mbz_recording_id;
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func downRenameMusicbrainzRecordingId(_ context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
func downRenameMusicbrainzRecordingId(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
alter table media_file
|
||||
rename column mbz_recording_id to mbz_track_id;
|
||||
`)
|
||||
|
||||
@ -29,7 +29,7 @@ func upAlterLyricColumn(ctx context.Context, tx *sql.Tx) error {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, err := tx.Query(`select id, lyrics_old FROM media_file WHERE lyrics_old <> '';`)
|
||||
rows, err := tx.QueryContext(ctx, `select id, lyrics_old FROM media_file WHERE lyrics_old <> '';`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -72,7 +72,7 @@ func upAlterLyricColumn(ctx context.Context, tx *sql.Tx) error {
|
||||
return err
|
||||
}
|
||||
|
||||
notice(tx, "A full rescan should be performed to pick up additional lyrics (existing lyrics have been preserved)")
|
||||
notice(ctx, tx, "A full rescan should be performed to pick up additional lyrics (existing lyrics have been preserved)")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@ -558,6 +558,6 @@ create index media_file_mbz_track_id
|
||||
return err
|
||||
}
|
||||
|
||||
func Down20240122223340(context.Context, *sql.Tx) error {
|
||||
func Down20240122223340(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -19,7 +19,7 @@ alter table media_file
|
||||
create index if not exists media_file_sample_rate
|
||||
on media_file (sample_rate);
|
||||
`)
|
||||
notice(tx, "A full rescan should be performed to pick up additional tags")
|
||||
notice(ctx, tx, "A full rescan should be performed to pick up additional tags")
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@ -61,6 +61,6 @@ create index annotation_starred_at
|
||||
return err
|
||||
}
|
||||
|
||||
func downRemoveAnnotationId(ctx context.Context, tx *sql.Tx) error {
|
||||
func downRemoveAnnotationId(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -97,8 +97,8 @@ insert into property (id, value) values ('PIDTrack', 'track_legacy') on conflict
|
||||
insert into property (id, value) values ('PIDAlbum', 'album_legacy') on conflict do nothing;
|
||||
`),
|
||||
func() error {
|
||||
notice(tx, "A full scan will be triggered to populate the new tables. This may take a while.")
|
||||
return forceFullRescan(tx)
|
||||
notice(ctx, tx, "A full scan will be triggered to populate the new tables. This may take a while.")
|
||||
return forceFullRescan(ctx, tx)
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -314,6 +314,6 @@ alter table artist
|
||||
}
|
||||
}
|
||||
|
||||
func downSupportNewScanner(context.Context, *sql.Tx) error {
|
||||
func downSupportNewScanner(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -75,6 +75,6 @@ create table playqueue_dg_tmp(
|
||||
return err
|
||||
}
|
||||
|
||||
func downPlayQueueCurrentToIndex(ctx context.Context, tx *sql.Tx) error {
|
||||
func downPlayQueueCurrentToIndex(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -16,6 +16,6 @@ func upAddFolderHash(ctx context.Context, tx *sql.Tx) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddFolderHash(ctx context.Context, tx *sql.Tx) error {
|
||||
func downAddFolderHash(_ context.Context, _ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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 ''`)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
})
|
||||
|
||||
@ -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...))
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
1
tests/fixtures/symlink_chain/evil1.mp3
vendored
Symbolic link
@ -0,0 +1 @@
|
||||
../index.html
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user