From 838ceee26d6f95dc998b6810d2008688b899c761 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 16 Jun 2026 21:47:15 -0400 Subject: [PATCH 1/4] perf(subsonic): speed up artist search3 deep-offset pagination (#5620) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(subsonic): speed up artist search3 deep-offset pagination Empty-query and FTS artist search (search3/search2) paginated via a CROSS JOIN library_artist + DISTINCT in Phase 1 purely for library access control. The DISTINCT forced a temp b-tree over the whole junction table on every page, making deep offsets O(offset): ~200ms at offset 299k on 300k artists. Replace it with a join-free EXISTS predicate keyed on artist.id, backed by a new covering index on library_artist(artist_id, library_id). EXISTS keeps artist as the ordered driver and never fans out rowids, so Phase 1 stays a plain ordered scan that LIMIT/OFFSET can short-circuit. Admin, headless, and all-libraries users skip the filter entirely (the dominant case) for a flat ordered walk over the primary key. Measured on a 300k-artist / 1M-song library: admin/all-libs pagination is ~4.5-5.4x faster at depth (~180ms to ~33ms at offset 400k); restricted subset users keep correct, gap-free pages while also getting faster. The narrowing artist filter is applied at the subsonic layer only when the request targets a strict subset of the user's libraries, so the common case (and the admin fast-path) is never burdened with a redundant predicate. * fix(subsonic): narrow artist search by library set, not count narrowsArtistLibraries decided whether to add the subsonic-layer artist narrowing filter by comparing len(requested) < len(accessible). musicFolderId is not deduplicated, so duplicate IDs inflated the requested count: a user requesting ?musicFolderId=1&musicFolderId=1&musicFolderId=2 against three accessible libraries produced len([1,1,2])==3, which is not < 3, so the filter was skipped and the user saw artists from the third library too. Compare as set membership instead: the request narrows iff some accessible library is absent from it (requested is always a subset of accessible, validated upstream by selectedMusicFolderIds). This is immune to duplicate IDs. Add a regression test that fails against the old length-based check. Also consolidate the repeated EXISTS/no-DISTINCT/O(page) rationale that the prior commit spread across five sites down to a single authoritative comment on ArtistLibraryFilter, with the call sites referencing it. * perf(subsonic): drop redundant library_artist covering index The migration added an index on library_artist(artist_id, library_id) on the theory that the restricted-subset artist-search EXISTS needed it to seek by artist_id. Benchmarking on a 405k-artist / 5-library dataset showed no benefit: the EXISTS subquery constrains both columns (artist_id = and library_id IN), so SQLite already resolves it as a covering-index seek on the existing (library_id, artist_id) UNIQUE autoindex. With the new index present the planner still picks the autoindex and ignores it. Drop the migration and correct the comment. Removing ~11MB of dead index plus its write-amplification on every library_artist insert/delete, for zero query gain. * fix(scanner): mark artists missing when they lose their last library Artist search Phase 1 filters on artist.missing and Phase 2 inner-joins library_artist, so a non-missing artist with no library_artist row (an orphan) takes a pagination slot in Phase 1 and then vanishes in Phase 2, shortening the page and shifting deep offsets. The admin/headless search fast-path walks artist unfiltered, so it is fully exposed to this. Two paths created such orphans without updating artist.missing: - RefreshStats deletes library_artist rows whose stats are '{}' (artist lost all content in a library) after every scan. This is the common source. - Library deletion cascades away the library's library_artist rows. Mark newly-orphaned artists missing at both sources, so the shared 'missing = false' search filter excludes them immediately instead of waiting for a later scan. In RefreshStats the update only runs when the cleanup actually removed rows (the only way a new orphan can appear), so steady-state scans pay nothing; measured ~160ms on 300k artists only when orphans can exist. * refactor(subsonic): address review feedback on artist search filter Code-review follow-ups to the artist search pagination change: - ArtistLibraryFilter: short-circuit to a constant-false predicate when no library IDs are given, avoiding a degenerate empty IN () subquery. - ArtistLibraryFilter: add an inner LIMIT 1 to the correlated EXISTS so SQLite cannot flatten it into a fan-out join (an artist in multiple of the user's libraries would otherwise yield duplicate rowids and corrupt pagination). - narrowsArtistLibraries: compare accessible-vs-requested as a set lookup instead of slices.Contains in a loop. - searchConfig.LibraryFilter: document that a join-free filter is now a correctness requirement (DISTINCT was removed), not just a performance one. * docs: trim verbose comments in artist search/orphan code Condense the over-explained comments added in this PR to the essential 'why', removing repeated cross-references and restatements of the adjacent code. * fix(scanner): heal pre-existing orphan artists on full refresh The orphan-marking added to RefreshStats only ran when its empty-stats cleanup deleted rows, so it reconciled newly-created orphans but not ones already left in the database by older versions (whose library_artist row was deleted before this fix existed). Such legacy orphans would surface in the admin/headless search fast-path as short/gappy pages. Also run the orphan-marking on a full refresh (allArtists), so a full scan — which upgrades commonly trigger and users can run manually — reconciles the backlog. No migration needed; the runtime fixes prevent recurrence. * perf(subsonic): extend artist search fast-path to all-library users applyLibraryFilterToSearchQuery only skipped the library filter for admin and headless processes. A regular (non-admin) user who can access every library has the same result set as an admin, but was still given the EXISTS filter — an O(offset) cost for a predicate that matches every non-missing artist anyway. Skip the filter for them too, using a cheap library CountAll() (a count over the tiny library table) compared against the user's library count. On any error it falls back to the filtered path, which is correct, just slower. * fix(scanner): log error as trailing arg, not explicit error key Signed-off-by: Deluan * test(scanner): e2e guard for orphan artists under PurgeMissing Adds an end-to-end scanner test for the orphan-artist invariant fixed in RefreshStats: with Scanner.PurgeMissing enabled, removing all of an artist's files hard-deletes them, cascades away their media_file_artists rows, and RefreshStats then drops the artist's emptied library_artist row. The test asserts no non-missing artist is left without a library_artist row. Verified it fails without the RefreshStats orphan-marking and passes with it. * test(scanner): assert the orphaned artist is marked missing The orphan e2e test only checked the aggregate no-orphan invariant (orphanCount == 0), which a fully-deleted artist or an un-cleaned row would also satisfy — so it could pass without exercising the fix. Assert Pink Floyd's row specifically: missing=false before, missing=true after, and absent from the non-missing results. Verified it fails without the RefreshStats orphan-marking. * test(scanner): drop misleading non-missing-list assertion for orphan GetAll has no default missing filter, but selectArtist inner-joins library_artist, so an orphaned artist (no junction row) is excluded from the results whether or not it is marked missing. The Not(ContainElement) check therefore passed for the wrong reason. The direct floydMissing() == 1 query is the assertion that actually validates the missing flag; keep that plus the orphan-count invariant and an over-marking guard on The Beatles. * test(scanner): document why orphan check reads the artist row directly Clarify that GetAll cannot observe the orphan: selectArtist inner-joins library_artist, so an artist with no junction row is excluded from results whether or not it is marked missing. Asserting on GetAll would pass even without the fix, so the test reads the artist row directly to check the missing flag. * test(scanner): return descriptive artist state for clearer failures floydState returns PRESENT/MISSING/NOT_FOUND instead of 0/1/-1, so a failure reads ': PRESENT to equal MISSING' rather than '0 to equal 1'. * refactor(subsonic): keep artist library scoping in the repository The search endpoint built a persistence-layer EXISTS predicate (persistence.ArtistLibraryFilter) and injected it into artistOpts.Filters — the only place the subsonic package reached into persistence, leaking a storage detail up two layers. Pass the same Eq{"library_id": ids} filter used for albums and songs, and let the artist repository translate it to the join-free library_artist predicate (scopeSearchToLibraries), where the junction knowledge belongs. The subset-vs- fast-path decision moves there too, so narrowsArtistLibraries and the persistence import are gone from the subsonic layer. Behavior is unchanged; coverage for the translation moves to artist_repository_test. * refactor(persistence): extract canonical markOrphansMissing helper The 'mark non-missing artists with no library_artist row as missing' invariant was hand-written as SQL in two places (RefreshStats and libraryRepository.Delete), in two slightly different dialects (not exists vs id not in). Extract a single artistRepository.markOrphansMissing method next to markMissing and call it from both sites, so the invariant has one definition. * fix(persistence): apply scoped library filter in both search phases Two bugs from moving the artist library-scoping into the repository: - Search() scoped opts.Filters for Phase 1 but still passed the original (unscoped) options to selectArtist, so Phase 2 re-applied the raw Eq{library_id} against the wrong columns and a restricted user's search returned nothing. Pass the scoped opts to both phases. - scopeSearchToLibraries dropped the filter unconditionally for admins, so an admin explicitly narrowing via musicFolderId (e.g. search3?musicFolderId=2) leaked content from other libraries. Compare the request against the user's visible library set (all libraries for admin/headless), narrowing whenever it is a strict subset. Both regressions were caught by the server/e2e multi-library suite. * fix(core): delete library and reconcile orphans in one transaction libraryRepository.Delete runs the FK-cascade delete and the orphaned-artist reconciliation (markOrphansMissing) as two writes on r.db. Called directly they autocommit separately, so an interruption between them could leave non-missing artists with no library_artist row — the orphan state the artist search fast-path forbids. Wrap the deletion in ds.WithTx at the core wrapper so both writes commit atomically; the watcher/scanner/broker side-effects stay post-commit. * refactor(persistence): unify artist search library scoping into one filter Phase 1 previously applied two overlapping library predicates: cfg.LibraryFilter (scoped to the user's libraries) AND options.Filters (the requested subset), producing two correlated EXISTS subqueries per rowid even though the request is always a subset of the user's libraries. And the 'does this user see everything' decision was implemented twice (userHasAllLibraries via CountAll vs scopeSearchToLibraries via set-membership), with applyLibraryFilterToSearchQuery as a third scoping path. Resolve the effective library scope once in Search() via searchScope (intersect the requested set with the user's visible libraries; nil = fast-path), clear opts.Filters, and realize that single scope as the only Phase-1 LibraryFilter. The visibility logic is now one pipeline: requestedLibraryIDs + visibleLibraryIDs + userSeesAllLibraries. Behavior unchanged; one EXISTS instead of two on the hot path, one source of truth for library visibility. * fix(persistence): harden artist search against malformed library_id filter Search consumed only an Eq{"library_id": []int} filter; an Eq whose library_id value wasn't []int slipped through unconsumed and would reach Phase 1's bare artist table (no library_id column) → SQL error. Recognize any Eq carrying a library_id key (isLibraryIDFilter) and always consume it, falling back to the user's visible scope for a malformed value. Non-library filters are still left in place for doSearch. * refactor(persistence): trim redundant comments and unexport artist library filter The artist-search-pagination work left dense explanatory comments, with the join-free / LIMIT-1 anti-flatten rationale and the orphan-artist mechanics each restated in several places. Consolidate each rationale into one canonical home (artistLibraryFilter for the EXISTS/LIMIT-1 trick, markOrphansMissing for the orphan lifecycle) and have the other sites reference it instead of repeating it. Also unexport ArtistLibraryFilter to artistLibraryFilter: its only caller is searchCfg in the same package and no test references it, so it never needed to be part of the package's exported surface. Comments only plus the rename; no behavior change. * refactor: add slice.ToSet and use it for the artist search subset check searchScope's subset test compared the requested libraries against the visible set with a nested slices.Contains, which is O(visible * requested). On an instance with many libraries (e.g. 100 libraries, a user granted 99) and an explicit musicFolderId request, that is ~9.8k comparisons; with a set it is ~200. Add a small reusable slice.ToSet helper (a slice -> map[T]struct{} set, collapsing duplicates) and use it to make the membership lookups O(1), restoring O(n+m) without the throwaway struct{}{} literal that an inline ToMap would need. No behavior change. * refactor(artist): move artistLibraryFilter to artist_repository Signed-off-by: Deluan --------- Signed-off-by: Deluan --- core/library.go | 6 +- persistence/artist_repository.go | 162 ++++++++++++++++--- persistence/artist_repository_test.go | 205 +++++++++++++++++++++++++ persistence/library_repository.go | 5 + persistence/library_repository_test.go | 46 ++++++ persistence/sql_search.go | 12 +- scanner/scanner_test.go | 64 ++++++++ server/subsonic/searching.go | 2 +- server/subsonic/searching_test.go | 44 +++--- utils/slice/slice.go | 10 ++ utils/slice/slice_test.go | 14 ++ 11 files changed, 515 insertions(+), 55 deletions(-) diff --git a/core/library.go b/core/library.go index 0bf3be9fa..365dcbd4c 100644 --- a/core/library.go +++ b/core/library.go @@ -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) } diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index aa3bc0776..56843b911 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -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...)) } diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index 7003efec3..603c5dd5e 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -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. diff --git a/persistence/library_repository.go b/persistence/library_repository.go index 1d8e6f35e..3789a71c9 100644 --- a/persistence/library_repository.go +++ b/persistence/library_repository.go @@ -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() diff --git a/persistence/library_repository_test.go b/persistence/library_repository_test.go index de7161643..1743df209 100644 --- a/persistence/library_repository_test.go +++ b/persistence/library_repository_test.go @@ -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") + }) + }) }) diff --git a/persistence/sql_search.go b/persistence/sql_search.go index 19cbaf24f..3049baae7 100644 --- a/persistence/sql_search.go +++ b/persistence/sql_search.go @@ -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) } diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index 7bf91d64f..cc3732bc3 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -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{ diff --git a/server/subsonic/searching.go b/server/subsonic/searching.go index fd7e29587..5d4989ae5 100644 --- a/server/subsonic/searching.go +++ b/server/subsonic/searching.go @@ -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 diff --git a/server/subsonic/searching_test.go b/server/subsonic/searching_test.go index 9a9c6af6f..4e72bd2e6 100644 --- a/server/subsonic/searching_test.go +++ b/server/subsonic/searching_test.go @@ -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() { diff --git a/utils/slice/slice.go b/utils/slice/slice.go index e87ac5388..73537c8f8 100644 --- a/utils/slice/slice.go +++ b/utils/slice/slice.go @@ -42,6 +42,16 @@ func ToMap[T any, K comparable, V any](s []T, transformFunc func(T) (K, V)) map[ return m } +// ToSet builds a set (a map keyed by the slice's elements) for O(1) membership tests. Duplicate +// elements collapse to a single key. +func ToSet[T comparable](s []T) map[T]struct{} { + m := make(map[T]struct{}, len(s)) + for _, item := range s { + m[item] = struct{}{} + } + return m +} + func CompactByFrequency[T comparable](list []T) []T { counters := make(map[T]int) for _, item := range list { diff --git a/utils/slice/slice_test.go b/utils/slice/slice_test.go index 64cb89d53..27548d693 100644 --- a/utils/slice/slice_test.go +++ b/utils/slice/slice_test.go @@ -81,6 +81,20 @@ var _ = Describe("Slice Utils", func() { }) }) + Describe("ToSet", func() { + It("returns empty set for an empty input", func() { + Expect(slice.ToSet([]int{})).To(BeEmpty()) + }) + + It("builds a set with one key per distinct element", func() { + result := slice.ToSet([]int{1, 2, 2, 3, 3, 3}) + Expect(result).To(HaveLen(3)) + Expect(result).To(HaveKey(1)) + Expect(result).To(HaveKey(2)) + Expect(result).To(HaveKey(3)) + }) + }) + Describe("CompactByFrequency", func() { It("returns empty slice for an empty input", func() { Expect(slice.CompactByFrequency([]int{})).To(BeEmpty()) From 6abc2ed517329ed3f744170623904dbb6719336a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Thu, 18 Jun 2026 08:57:45 -0400 Subject: [PATCH 2/4] fix(transcoding): preserve source metadata when transcoding downloads (#5628) * fix(transcoding): preserve source metadata when transcoding downloads Default transcoding commands used `-map 0:a:0` with no metadata mapping, so transcoded files lost all source tags (title, artist, album, etc.). Downloads in the original format were unaffected because the file is copied byte-for-byte. Add `-map_metadata 0 -map_metadata 0:s:0` to the default commands. Both flags are required: `-map_metadata 0` copies format-level tags (MP3/FLAC sources) and `-map_metadata 0:s:0` copies stream-level tags (OPUS/OGG sources), which store tags at different levels. The flags are added in three coordinated places, since for users on the default command the args are built programmatically (buildDynamicArgs) rather than from the stored command string: - consts.go default commands, for new installations - buildDynamicArgs, the active path for default-command users - a migration updating only rows that still hold the exact old default, so customized commands are left untouched AAC is included for consistency but remains a no-op: its `-f adts` container cannot hold metadata, and the MP4 alternative breaks pipe streaming. Fixes #5623 * fix(transcoding): target audio stream for metadata and propagate ctx Address review feedback on the metadata-preservation change: - Use `-map_metadata 0:s:a:0` instead of `0:s:0` to copy tags from the first audio stream specifically. When a source has embedded cover art exposed as a video stream at index 0 (common in music files), `0:s:0` pulls the image stream's metadata and the audio tags are lost. Verified empirically with ffmpeg 7.1.3: a source with video at stream 0 and a tagged audio stream loses its title under `0:s:0` but keeps it under `0:s:a:0`; audio-only OPUS/MP3/FLAC sources are unaffected by the change. - Propagate the migration context via `tx.ExecContext(ctx, ...)` instead of discarding it, so the migration honors cancellation/timeouts. Claude-Session: https://claude.ai/code/session_015iFHDzX53wCKt11qFHMeZk --- consts/consts.go | 8 +-- core/ffmpeg/ffmpeg.go | 8 +++ core/ffmpeg/ffmpeg_test.go | 14 ++-- ...09_add_metadata_to_default_transcodings.go | 64 +++++++++++++++++++ 4 files changed, 86 insertions(+), 8 deletions(-) create mode 100644 db/migrations/20260618120509_add_metadata_to_default_transcodings.go diff --git a/consts/consts.go b/consts/consts.go index 4baf4610d..3795b590a 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -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 -", }, } ) diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index 58e9fd152..3d4cd0e72 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -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) } diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index 2e2895738..9c20e6c05 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -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", diff --git a/db/migrations/20260618120509_add_metadata_to_default_transcodings.go b/db/migrations/20260618120509_add_metadata_to_default_transcodings.go new file mode 100644 index 000000000..2186cda91 --- /dev/null +++ b/db/migrations/20260618120509_add_metadata_to_default_transcodings.go @@ -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 +} From 32ac53dc9f7270828535c719b716b08cc481a3eb Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 18 Jun 2026 09:50:26 -0400 Subject: [PATCH 3/4] refactor(migrations): propagate context.Context through all DB calls Thread the context.Context that goose.UpContext already passes into every migration through to all DB calls: tx.Exec/Query/QueryRow become tx.ExecContext/QueryContext/QueryRowContext with ctx. The shared helpers in migration.go (notice, forceFullRescan, isDBInitialized) gain a ctx parameter and all call sites are updated. No-op migration functions use blank params (_ context.Context, _ *sql.Tx). This is a behavior-preserving change: the SQL, arguments, and ordering of every migration are unchanged; only cancellation/deadline propagation is added. Add a forbidigo lint rule scoped to db/migrations/ that forbids the non-context tx.Exec/Query/QueryRow forms, preventing regression. Signed-off-by: Deluan --- .golangci.yml | 12 ++++++++ db/migrations/20200130083147_create_schema.go | 6 ++-- .../20200131183653_standardize_item_type.go | 8 +++--- ...00208222418_add_defaults_to_annotations.go | 6 ++-- ...20200220143731_change_duration_to_float.go | 8 +++--- ...0310171621_enable_search_by_albumartist.go | 8 +++--- ...81627_add_transcoding_and_player_tables.go | 8 +++--- ...319211049_merge_search_into_main_tables.go | 10 +++---- .../20200325185135_add_album_artist_id.go | 10 +++---- ...00326090707_fix_album_artists_importing.go | 8 +++--- .../20200327193744_add_year_range_to_album.go | 10 +++---- db/migrations/20200404214704_add_indexes.go | 6 ++-- ...9002249_enable_search_by_tracks_artists.go | 8 +++--- ...created_and_updated_fields_to_playlists.go | 6 ++-- ...200418110522_reindex_to_fix_album_years.go | 8 +++--- ...2708_reindex_to_change_full_text_search.go | 8 +++--- .../20200423204116_add_sort_fields.go | 10 +++---- .../20200508093059_add_artist_song_count.go | 10 +++---- .../20200512104202_add_disc_subtitle.go | 10 +++---- ...0200516140647_add_playlist_tracks_table.go | 10 +++---- .../20200608153717_referential_integrity.go | 28 +++++++++---------- ...20200706231659_add_default_transcodings.go | 6 ++-- .../20200710211442_add_playlist_path.go | 6 ++-- ...20200731095603_create_play_queues_table.go | 6 ++-- .../20200801101355_create_bookmark_table.go | 6 ++-- ...0819111809_drop_email_unique_constraint.go | 6 ++-- .../20201003111749_add_starred_at_index.go | 6 ++-- .../20201010162350_add_album_size.go | 6 ++-- ...20201012210022_add_artist_playlist_size.go | 6 ++-- db/migrations/20201021085410_add_mbids.go | 10 +++---- .../20201021093209_add_media_file_indexes.go | 6 ++-- ...01021135455_add_media_file_artist_index.go | 6 ++-- .../20201030162009_add_artist_info_table.go | 6 ++-- .../20201110205344_add_comments_and_lyrics.go | 10 +++---- .../20201128100726_add_real-path_option.go | 6 ++-- ...01213124814_add_all_artist_ids_to_album.go | 12 ++++---- .../20210322132848_add_timestamp_indexes.go | 6 ++-- .../20210418232815_fix_album_comments.go | 8 +++--- .../20210430212322_add_bpm_metadata.go | 10 +++---- .../20210530121921_create_shares_table.go | 6 ++-- .../20210601231734_update_share_fieldnames.go | 6 ++-- .../20210616150710_encrypt_all_passwords.go | 4 +-- ...1716_drop_player_name_unique_constraint.go | 6 ++-- ...add_user_prefs_player_scrobbler_enabled.go | 16 +++++------ ...add_referential_integrity_to_user_props.go | 6 ++-- .../20210626213026_add_scrobble_buffer.go | 6 ++-- .../20210715151153_add_genre_tables.go | 10 +++---- .../20210821212604_add_mediafile_channels.go | 10 +++---- .../20211008205505_add_smart_playlist.go | 6 ++-- ...023184825_add_order_title_to_media_file.go | 12 ++++---- ...1026191915_unescape_lyrics_and_comments.go | 6 ++-- .../20211029213200_add_userid_to_playlist.go | 6 ++-- ...215414_add_alphabetical_by_artist_index.go | 6 ++-- ...0211105162746_remove_invalid_artist_ids.go | 6 ++-- ...231849_add_musicbrainz_release_track_id.go | 10 +++---- .../20221219112733_add_album_image_paths.go | 10 +++---- .../20221219140528_remove_cover_art_id.go | 10 +++---- .../20230112111457_add_album_paths.go | 10 +++---- .../20230114121537_touch_playlists.go | 6 ++-- .../20230115103212_create_internet_radio.go | 6 ++-- .../20230117155559_add_replaygain_metadata.go | 10 +++---- .../20230117180400_add_album_info.go | 6 ++-- .../20230119152657_recreate_share_table.go | 6 ++-- ...230202143713_change_path_list_separator.go | 8 +++--- ...81414_change_image_files_list_separator.go | 6 ++-- .../20230310222612_add_download_to_share.go | 6 ++-- .../20230515184510_add_release_date.go | 10 +++---- ...6214944_rename_musicbrainz_recording_id.go | 8 +++--- .../20231209211223_alter_lyric_column.go | 4 +-- ...0_add_default_values_to_null_columns.go.go | 2 +- .../20240511210036_add_sample_rate.go | 2 +- .../20240629152843_remove_annotation_id.go | 2 +- .../20241026183640_support_new_scanner.go | 6 ++-- ...250611010101_playqueue_current_to_index.go | 2 +- .../20250701010101_add_folder_hash.go | 2 +- .../20250701010103_add_library_stats.go | 2 +- ...1010104_make_replaygain_fields_nullable.go | 2 +- .../20260220173400_add_fts5_search.go | 2 +- ...75815_add_codec_and_update_transcodings.go | 22 +++++++-------- .../20260309120007_fix_probe_data_null.go | 8 +++--- ...60309203355_ensure_default_transcodings.go | 8 +++--- ...0260310113858_fix_aac_transcode_command.go | 6 ++-- .../20260513173954_move_ss_before_input.go | 8 +++--- db/migrations/migration.go | 14 +++++----- 84 files changed, 327 insertions(+), 315 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 28eb375a5..76eb882ca 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -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 diff --git a/db/migrations/20200130083147_create_schema.go b/db/migrations/20200130083147_create_schema.go index 2fae4f57d..250fb00a5 100644 --- a/db/migrations/20200130083147_create_schema.go +++ b/db/migrations/20200130083147_create_schema.go @@ -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 } diff --git a/db/migrations/20200131183653_standardize_item_type.go b/db/migrations/20200131183653_standardize_item_type.go index 471dc8002..bf7d9d5f7 100644 --- a/db/migrations/20200131183653_standardize_item_type.go +++ b/db/migrations/20200131183653_standardize_item_type.go @@ -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 diff --git a/db/migrations/20200208222418_add_defaults_to_annotations.go b/db/migrations/20200208222418_add_defaults_to_annotations.go index d058b02c3..6807c8ad2 100644 --- a/db/migrations/20200208222418_add_defaults_to_annotations.go +++ b/db/migrations/20200208222418_add_defaults_to_annotations.go @@ -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 } diff --git a/db/migrations/20200220143731_change_duration_to_float.go b/db/migrations/20200220143731_change_duration_to_float.go index 72b785ef8..ea5465ade 100644 --- a/db/migrations/20200220143731_change_duration_to_float.go +++ b/db/migrations/20200220143731_change_duration_to_float.go @@ -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 } diff --git a/db/migrations/20200310171621_enable_search_by_albumartist.go b/db/migrations/20200310171621_enable_search_by_albumartist.go index 373e0a475..73436c890 100644 --- a/db/migrations/20200310171621_enable_search_by_albumartist.go +++ b/db/migrations/20200310171621_enable_search_by_albumartist.go @@ -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 } diff --git a/db/migrations/20200310181627_add_transcoding_and_player_tables.go b/db/migrations/20200310181627_add_transcoding_and_player_tables.go index 3be91ac35..ef872c4ae 100644 --- a/db/migrations/20200310181627_add_transcoding_and_player_tables.go +++ b/db/migrations/20200310181627_add_transcoding_and_player_tables.go @@ -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; `) diff --git a/db/migrations/20200319211049_merge_search_into_main_tables.go b/db/migrations/20200319211049_merge_search_into_main_tables.go index f888cdd4c..a7a6ff0f9 100644 --- a/db/migrations/20200319211049_merge_search_into_main_tables.go +++ b/db/migrations/20200319211049_merge_search_into_main_tables.go @@ -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 } diff --git a/db/migrations/20200325185135_add_album_artist_id.go b/db/migrations/20200325185135_add_album_artist_id.go index f01f2c558..01537f886 100644 --- a/db/migrations/20200325185135_add_album_artist_id.go +++ b/db/migrations/20200325185135_add_album_artist_id.go @@ -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 } diff --git a/db/migrations/20200326090707_fix_album_artists_importing.go b/db/migrations/20200326090707_fix_album_artists_importing.go index c42e8c327..17afe37fe 100644 --- a/db/migrations/20200326090707_fix_album_artists_importing.go +++ b/db/migrations/20200326090707_fix_album_artists_importing.go @@ -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 } diff --git a/db/migrations/20200327193744_add_year_range_to_album.go b/db/migrations/20200327193744_add_year_range_to_album.go index 66f2b23e8..d9b048e22 100644 --- a/db/migrations/20200327193744_add_year_range_to_album.go +++ b/db/migrations/20200327193744_add_year_range_to_album.go @@ -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 } diff --git a/db/migrations/20200404214704_add_indexes.go b/db/migrations/20200404214704_add_indexes.go index 6207b0a3d..8b8d8607e 100644 --- a/db/migrations/20200404214704_add_indexes.go +++ b/db/migrations/20200404214704_add_indexes.go @@ -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 } diff --git a/db/migrations/20200409002249_enable_search_by_tracks_artists.go b/db/migrations/20200409002249_enable_search_by_tracks_artists.go index 22006c8af..482341a89 100644 --- a/db/migrations/20200409002249_enable_search_by_tracks_artists.go +++ b/db/migrations/20200409002249_enable_search_by_tracks_artists.go @@ -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 } diff --git a/db/migrations/20200411164603_add_created_and_updated_fields_to_playlists.go b/db/migrations/20200411164603_add_created_and_updated_fields_to_playlists.go index 266dc087d..4aa502b4b 100644 --- a/db/migrations/20200411164603_add_created_and_updated_fields_to_playlists.go +++ b/db/migrations/20200411164603_add_created_and_updated_fields_to_playlists.go @@ -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 } diff --git a/db/migrations/20200418110522_reindex_to_fix_album_years.go b/db/migrations/20200418110522_reindex_to_fix_album_years.go index 22b024cea..54e03f4c6 100644 --- a/db/migrations/20200418110522_reindex_to_fix_album_years.go +++ b/db/migrations/20200418110522_reindex_to_fix_album_years.go @@ -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 } diff --git a/db/migrations/20200419222708_reindex_to_change_full_text_search.go b/db/migrations/20200419222708_reindex_to_change_full_text_search.go index efeb1bb84..89e3ccee5 100644 --- a/db/migrations/20200419222708_reindex_to_change_full_text_search.go +++ b/db/migrations/20200419222708_reindex_to_change_full_text_search.go @@ -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 } diff --git a/db/migrations/20200423204116_add_sort_fields.go b/db/migrations/20200423204116_add_sort_fields.go index 4097a9d60..a51bb2270 100644 --- a/db/migrations/20200423204116_add_sort_fields.go +++ b/db/migrations/20200423204116_add_sort_fields.go @@ -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 } diff --git a/db/migrations/20200508093059_add_artist_song_count.go b/db/migrations/20200508093059_add_artist_song_count.go index aac78e698..72a47bc94 100644 --- a/db/migrations/20200508093059_add_artist_song_count.go +++ b/db/migrations/20200508093059_add_artist_song_count.go @@ -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 } diff --git a/db/migrations/20200512104202_add_disc_subtitle.go b/db/migrations/20200512104202_add_disc_subtitle.go index b3e907d8d..29734e0c0 100644 --- a/db/migrations/20200512104202_add_disc_subtitle.go +++ b/db/migrations/20200512104202_add_disc_subtitle.go @@ -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 } diff --git a/db/migrations/20200516140647_add_playlist_tracks_table.go b/db/migrations/20200516140647_add_playlist_tracks_table.go index fcaae9d8e..59265e410 100644 --- a/db/migrations/20200516140647_add_playlist_tracks_table.go +++ b/db/migrations/20200516140647_add_playlist_tracks_table.go @@ -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 } diff --git a/db/migrations/20200608153717_referential_integrity.go b/db/migrations/20200608153717_referential_integrity.go index 2959237fa..c9c766f7e 100644 --- a/db/migrations/20200608153717_referential_integrity.go +++ b/db/migrations/20200608153717_referential_integrity.go @@ -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 } diff --git a/db/migrations/20200706231659_add_default_transcodings.go b/db/migrations/20200706231659_add_default_transcodings.go index a498d32b0..e87481ae1 100644 --- a/db/migrations/20200706231659_add_default_transcodings.go +++ b/db/migrations/20200706231659_add_default_transcodings.go @@ -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 } diff --git a/db/migrations/20200710211442_add_playlist_path.go b/db/migrations/20200710211442_add_playlist_path.go index 8abfed6cf..32cc8d034 100644 --- a/db/migrations/20200710211442_add_playlist_path.go +++ b/db/migrations/20200710211442_add_playlist_path.go @@ -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 } diff --git a/db/migrations/20200731095603_create_play_queues_table.go b/db/migrations/20200731095603_create_play_queues_table.go index d63a1ecb9..7a27137bc 100644 --- a/db/migrations/20200731095603_create_play_queues_table.go +++ b/db/migrations/20200731095603_create_play_queues_table.go @@ -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 } diff --git a/db/migrations/20200801101355_create_bookmark_table.go b/db/migrations/20200801101355_create_bookmark_table.go index fe68fafd7..df814d7b8 100644 --- a/db/migrations/20200801101355_create_bookmark_table.go +++ b/db/migrations/20200801101355_create_bookmark_table.go @@ -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 } diff --git a/db/migrations/20200819111809_drop_email_unique_constraint.go b/db/migrations/20200819111809_drop_email_unique_constraint.go index b2dd4285c..8259ad3fe 100644 --- a/db/migrations/20200819111809_drop_email_unique_constraint.go +++ b/db/migrations/20200819111809_drop_email_unique_constraint.go @@ -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 } diff --git a/db/migrations/20201003111749_add_starred_at_index.go b/db/migrations/20201003111749_add_starred_at_index.go index 7ee7a283f..b46430743 100644 --- a/db/migrations/20201003111749_add_starred_at_index.go +++ b/db/migrations/20201003111749_add_starred_at_index.go @@ -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 } diff --git a/db/migrations/20201010162350_add_album_size.go b/db/migrations/20201010162350_add_album_size.go index f1182ab6c..df1fa8ca2 100644 --- a/db/migrations/20201010162350_add_album_size.go +++ b/db/migrations/20201010162350_add_album_size.go @@ -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 } diff --git a/db/migrations/20201012210022_add_artist_playlist_size.go b/db/migrations/20201012210022_add_artist_playlist_size.go index 4eb67f14e..1c738dd1e 100644 --- a/db/migrations/20201012210022_add_artist_playlist_size.go +++ b/db/migrations/20201012210022_add_artist_playlist_size.go @@ -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 } diff --git a/db/migrations/20201021085410_add_mbids.go b/db/migrations/20201021085410_add_mbids.go index 624bb1a67..53001fc73 100644 --- a/db/migrations/20201021085410_add_mbids.go +++ b/db/migrations/20201021085410_add_mbids.go @@ -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 } diff --git a/db/migrations/20201021093209_add_media_file_indexes.go b/db/migrations/20201021093209_add_media_file_indexes.go index f3a800949..7d6ad4965 100644 --- a/db/migrations/20201021093209_add_media_file_indexes.go +++ b/db/migrations/20201021093209_add_media_file_indexes.go @@ -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 } diff --git a/db/migrations/20201021135455_add_media_file_artist_index.go b/db/migrations/20201021135455_add_media_file_artist_index.go index ca04d8a20..e8f22c3a7 100644 --- a/db/migrations/20201021135455_add_media_file_artist_index.go +++ b/db/migrations/20201021135455_add_media_file_artist_index.go @@ -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 } diff --git a/db/migrations/20201030162009_add_artist_info_table.go b/db/migrations/20201030162009_add_artist_info_table.go index f2917ae49..e33e15c23 100644 --- a/db/migrations/20201030162009_add_artist_info_table.go +++ b/db/migrations/20201030162009_add_artist_info_table.go @@ -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 } diff --git a/db/migrations/20201110205344_add_comments_and_lyrics.go b/db/migrations/20201110205344_add_comments_and_lyrics.go index 5bb17b8d0..c60917bdd 100644 --- a/db/migrations/20201110205344_add_comments_and_lyrics.go +++ b/db/migrations/20201110205344_add_comments_and_lyrics.go @@ -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 } diff --git a/db/migrations/20201128100726_add_real-path_option.go b/db/migrations/20201128100726_add_real-path_option.go index db102dfa9..4b3f62128 100644 --- a/db/migrations/20201128100726_add_real-path_option.go +++ b/db/migrations/20201128100726_add_real-path_option.go @@ -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 } diff --git a/db/migrations/20201213124814_add_all_artist_ids_to_album.go b/db/migrations/20201213124814_add_all_artist_ids_to_album.go index 170497f5c..81c30d611 100644 --- a/db/migrations/20201213124814_add_all_artist_ids_to_album.go +++ b/db/migrations/20201213124814_add_all_artist_ids_to_album.go @@ -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 } diff --git a/db/migrations/20210322132848_add_timestamp_indexes.go b/db/migrations/20210322132848_add_timestamp_indexes.go index 3341dd3d2..5ed250fea 100644 --- a/db/migrations/20210322132848_add_timestamp_indexes.go +++ b/db/migrations/20210322132848_add_timestamp_indexes.go @@ -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 } diff --git a/db/migrations/20210418232815_fix_album_comments.go b/db/migrations/20210418232815_fix_album_comments.go index 59067640a..3c7ed86c1 100644 --- a/db/migrations/20210418232815_fix_album_comments.go +++ b/db/migrations/20210418232815_fix_album_comments.go @@ -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 } diff --git a/db/migrations/20210430212322_add_bpm_metadata.go b/db/migrations/20210430212322_add_bpm_metadata.go index 721c9e179..00a0f1447 100644 --- a/db/migrations/20210430212322_add_bpm_metadata.go +++ b/db/migrations/20210430212322_add_bpm_metadata.go @@ -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 } diff --git a/db/migrations/20210530121921_create_shares_table.go b/db/migrations/20210530121921_create_shares_table.go index e9208bd69..d9e902a43 100644 --- a/db/migrations/20210530121921_create_shares_table.go +++ b/db/migrations/20210530121921_create_shares_table.go @@ -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 } diff --git a/db/migrations/20210601231734_update_share_fieldnames.go b/db/migrations/20210601231734_update_share_fieldnames.go index 965c0186e..5a459a34c 100644 --- a/db/migrations/20210601231734_update_share_fieldnames.go +++ b/db/migrations/20210601231734_update_share_fieldnames.go @@ -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 } diff --git a/db/migrations/20210616150710_encrypt_all_passwords.go b/db/migrations/20210616150710_encrypt_all_passwords.go index f67e3fb0a..dc8a9abd4 100644 --- a/db/migrations/20210616150710_encrypt_all_passwords.go +++ b/db/migrations/20210616150710_encrypt_all_passwords.go @@ -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 } diff --git a/db/migrations/20210619231716_drop_player_name_unique_constraint.go b/db/migrations/20210619231716_drop_player_name_unique_constraint.go index 200332156..734ffc340 100644 --- a/db/migrations/20210619231716_drop_player_name_unique_constraint.go +++ b/db/migrations/20210619231716_drop_player_name_unique_constraint.go @@ -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 } diff --git a/db/migrations/20210623155401_add_user_prefs_player_scrobbler_enabled.go b/db/migrations/20210623155401_add_user_prefs_player_scrobbler_enabled.go index 5257dfab3..aa5e7a8f0 100644 --- a/db/migrations/20210623155401_add_user_prefs_player_scrobbler_enabled.go +++ b/db/migrations/20210623155401_add_user_prefs_player_scrobbler_enabled.go @@ -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 } diff --git a/db/migrations/20210625223901_add_referential_integrity_to_user_props.go b/db/migrations/20210625223901_add_referential_integrity_to_user_props.go index 033392d93..b2f93b4e3 100644 --- a/db/migrations/20210625223901_add_referential_integrity_to_user_props.go +++ b/db/migrations/20210625223901_add_referential_integrity_to_user_props.go @@ -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 } diff --git a/db/migrations/20210626213026_add_scrobble_buffer.go b/db/migrations/20210626213026_add_scrobble_buffer.go index 1c4d0de2a..75d9d681c 100644 --- a/db/migrations/20210626213026_add_scrobble_buffer.go +++ b/db/migrations/20210626213026_add_scrobble_buffer.go @@ -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 } diff --git a/db/migrations/20210715151153_add_genre_tables.go b/db/migrations/20210715151153_add_genre_tables.go index ab2c54239..143f9c72b 100644 --- a/db/migrations/20210715151153_add_genre_tables.go +++ b/db/migrations/20210715151153_add_genre_tables.go @@ -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 } diff --git a/db/migrations/20210821212604_add_mediafile_channels.go b/db/migrations/20210821212604_add_mediafile_channels.go index 9a0988b17..ee18be01b 100644 --- a/db/migrations/20210821212604_add_mediafile_channels.go +++ b/db/migrations/20210821212604_add_mediafile_channels.go @@ -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 } diff --git a/db/migrations/20211008205505_add_smart_playlist.go b/db/migrations/20211008205505_add_smart_playlist.go index c8ed67c47..0d2d1ad4e 100644 --- a/db/migrations/20211008205505_add_smart_playlist.go +++ b/db/migrations/20211008205505_add_smart_playlist.go @@ -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 } diff --git a/db/migrations/20211023184825_add_order_title_to_media_file.go b/db/migrations/20211023184825_add_order_title_to_media_file.go index ee6fc67d1..4a2ae4047 100644 --- a/db/migrations/20211023184825_add_order_title_to_media_file.go +++ b/db/migrations/20211023184825_add_order_title_to_media_file.go @@ -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 } diff --git a/db/migrations/20211026191915_unescape_lyrics_and_comments.go b/db/migrations/20211026191915_unescape_lyrics_and_comments.go index d4ba5e194..a7969ffed 100644 --- a/db/migrations/20211026191915_unescape_lyrics_and_comments.go +++ b/db/migrations/20211026191915_unescape_lyrics_and_comments.go @@ -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 } diff --git a/db/migrations/20211029213200_add_userid_to_playlist.go b/db/migrations/20211029213200_add_userid_to_playlist.go index e262fc205..909ea1c54 100644 --- a/db/migrations/20211029213200_add_userid_to_playlist.go +++ b/db/migrations/20211029213200_add_userid_to_playlist.go @@ -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 } diff --git a/db/migrations/20211102215414_add_alphabetical_by_artist_index.go b/db/migrations/20211102215414_add_alphabetical_by_artist_index.go index 4ab4305d0..f786b69e7 100644 --- a/db/migrations/20211102215414_add_alphabetical_by_artist_index.go +++ b/db/migrations/20211102215414_add_alphabetical_by_artist_index.go @@ -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 } diff --git a/db/migrations/20211105162746_remove_invalid_artist_ids.go b/db/migrations/20211105162746_remove_invalid_artist_ids.go index 5e078c820..8c9887dd1 100644 --- a/db/migrations/20211105162746_remove_invalid_artist_ids.go +++ b/db/migrations/20211105162746_remove_invalid_artist_ids.go @@ -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 } diff --git a/db/migrations/20220724231849_add_musicbrainz_release_track_id.go b/db/migrations/20220724231849_add_musicbrainz_release_track_id.go index 481762117..42e13a1e5 100644 --- a/db/migrations/20220724231849_add_musicbrainz_release_track_id.go +++ b/db/migrations/20220724231849_add_musicbrainz_release_track_id.go @@ -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 } diff --git a/db/migrations/20221219112733_add_album_image_paths.go b/db/migrations/20221219112733_add_album_image_paths.go index ee9c77c8a..f8ebd40e9 100644 --- a/db/migrations/20221219112733_add_album_image_paths.go +++ b/db/migrations/20221219112733_add_album_image_paths.go @@ -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 } diff --git a/db/migrations/20221219140528_remove_cover_art_id.go b/db/migrations/20221219140528_remove_cover_art_id.go index a1eaa89f9..30f86297a 100644 --- a/db/migrations/20221219140528_remove_cover_art_id.go +++ b/db/migrations/20221219140528_remove_cover_art_id.go @@ -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 } diff --git a/db/migrations/20230112111457_add_album_paths.go b/db/migrations/20230112111457_add_album_paths.go index 2dfb9a747..2819522a1 100644 --- a/db/migrations/20230112111457_add_album_paths.go +++ b/db/migrations/20230112111457_add_album_paths.go @@ -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 } diff --git a/db/migrations/20230114121537_touch_playlists.go b/db/migrations/20230114121537_touch_playlists.go index 0f10e275c..71959b0a8 100644 --- a/db/migrations/20230114121537_touch_playlists.go +++ b/db/migrations/20230114121537_touch_playlists.go @@ -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 } diff --git a/db/migrations/20230115103212_create_internet_radio.go b/db/migrations/20230115103212_create_internet_radio.go index 5c014dac2..3e0da348f 100644 --- a/db/migrations/20230115103212_create_internet_radio.go +++ b/db/migrations/20230115103212_create_internet_radio.go @@ -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 } diff --git a/db/migrations/20230117155559_add_replaygain_metadata.go b/db/migrations/20230117155559_add_replaygain_metadata.go index d6be3b313..3aad70925 100644 --- a/db/migrations/20230117155559_add_replaygain_metadata.go +++ b/db/migrations/20230117155559_add_replaygain_metadata.go @@ -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 } diff --git a/db/migrations/20230117180400_add_album_info.go b/db/migrations/20230117180400_add_album_info.go index 5d6dd8230..750d3838f 100644 --- a/db/migrations/20230117180400_add_album_info.go +++ b/db/migrations/20230117180400_add_album_info.go @@ -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 } diff --git a/db/migrations/20230119152657_recreate_share_table.go b/db/migrations/20230119152657_recreate_share_table.go index e1ae816c0..10eff31ca 100644 --- a/db/migrations/20230119152657_recreate_share_table.go +++ b/db/migrations/20230119152657_recreate_share_table.go @@ -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 } diff --git a/db/migrations/20230202143713_change_path_list_separator.go b/db/migrations/20230202143713_change_path_list_separator.go index 78b030ae4..fb5f2be1a 100644 --- a/db/migrations/20230202143713_change_path_list_separator.go +++ b/db/migrations/20230202143713_change_path_list_separator.go @@ -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 } diff --git a/db/migrations/20230209181414_change_image_files_list_separator.go b/db/migrations/20230209181414_change_image_files_list_separator.go index 7f4d4cb0e..e5dc4ab43 100644 --- a/db/migrations/20230209181414_change_image_files_list_separator.go +++ b/db/migrations/20230209181414_change_image_files_list_separator.go @@ -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 } diff --git a/db/migrations/20230310222612_add_download_to_share.go b/db/migrations/20230310222612_add_download_to_share.go index ed2879ec3..3ee24cc77 100644 --- a/db/migrations/20230310222612_add_download_to_share.go +++ b/db/migrations/20230310222612_add_download_to_share.go @@ -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 } diff --git a/db/migrations/20230515184510_add_release_date.go b/db/migrations/20230515184510_add_release_date.go index 1141a1e74..f22bdfae8 100644 --- a/db/migrations/20230515184510_add_release_date.go +++ b/db/migrations/20230515184510_add_release_date.go @@ -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 } diff --git a/db/migrations/20230616214944_rename_musicbrainz_recording_id.go b/db/migrations/20230616214944_rename_musicbrainz_recording_id.go index 170fc264c..562a59bb5 100644 --- a/db/migrations/20230616214944_rename_musicbrainz_recording_id.go +++ b/db/migrations/20230616214944_rename_musicbrainz_recording_id.go @@ -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; `) diff --git a/db/migrations/20231209211223_alter_lyric_column.go b/db/migrations/20231209211223_alter_lyric_column.go index ac73fc98f..891cb9f5b 100644 --- a/db/migrations/20231209211223_alter_lyric_column.go +++ b/db/migrations/20231209211223_alter_lyric_column.go @@ -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 } diff --git a/db/migrations/20240122223340_add_default_values_to_null_columns.go.go b/db/migrations/20240122223340_add_default_values_to_null_columns.go.go index a65b0aefd..518d125e4 100644 --- a/db/migrations/20240122223340_add_default_values_to_null_columns.go.go +++ b/db/migrations/20240122223340_add_default_values_to_null_columns.go.go @@ -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 } diff --git a/db/migrations/20240511210036_add_sample_rate.go b/db/migrations/20240511210036_add_sample_rate.go index 619cdcffd..76b809c36 100644 --- a/db/migrations/20240511210036_add_sample_rate.go +++ b/db/migrations/20240511210036_add_sample_rate.go @@ -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 } diff --git a/db/migrations/20240629152843_remove_annotation_id.go b/db/migrations/20240629152843_remove_annotation_id.go index b450b26d4..972932e10 100644 --- a/db/migrations/20240629152843_remove_annotation_id.go +++ b/db/migrations/20240629152843_remove_annotation_id.go @@ -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 } diff --git a/db/migrations/20241026183640_support_new_scanner.go b/db/migrations/20241026183640_support_new_scanner.go index fcbef7e4e..f5899f08f 100644 --- a/db/migrations/20241026183640_support_new_scanner.go +++ b/db/migrations/20241026183640_support_new_scanner.go @@ -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 } diff --git a/db/migrations/20250611010101_playqueue_current_to_index.go b/db/migrations/20250611010101_playqueue_current_to_index.go index d9250eba2..1b83c0b35 100644 --- a/db/migrations/20250611010101_playqueue_current_to_index.go +++ b/db/migrations/20250611010101_playqueue_current_to_index.go @@ -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 } diff --git a/db/migrations/20250701010101_add_folder_hash.go b/db/migrations/20250701010101_add_folder_hash.go index e82a0749f..c350d31f5 100644 --- a/db/migrations/20250701010101_add_folder_hash.go +++ b/db/migrations/20250701010101_add_folder_hash.go @@ -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 } diff --git a/db/migrations/20250701010103_add_library_stats.go b/db/migrations/20250701010103_add_library_stats.go index 8025229cc..a84758a04 100644 --- a/db/migrations/20250701010103_add_library_stats.go +++ b/db/migrations/20250701010103_add_library_stats.go @@ -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 } diff --git a/db/migrations/20250701010104_make_replaygain_fields_nullable.go b/db/migrations/20250701010104_make_replaygain_fields_nullable.go index 163608d32..c6beb2a51 100644 --- a/db/migrations/20250701010104_make_replaygain_fields_nullable.go +++ b/db/migrations/20250701010104_make_replaygain_fields_nullable.go @@ -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 } diff --git a/db/migrations/20260220173400_add_fts5_search.go b/db/migrations/20260220173400_add_fts5_search.go index dc4cd647b..6f2bde429 100644 --- a/db/migrations/20260220173400_add_fts5_search.go +++ b/db/migrations/20260220173400_add_fts5_search.go @@ -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 ''`) diff --git a/db/migrations/20260307175815_add_codec_and_update_transcodings.go b/db/migrations/20260307175815_add_codec_and_update_transcodings.go index 4e8b1b7f5..f52f48440 100644 --- a/db/migrations/20260307175815_add_codec_and_update_transcodings.go +++ b/db/migrations/20260307175815_add_codec_and_update_transcodings.go @@ -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 } diff --git a/db/migrations/20260309120007_fix_probe_data_null.go b/db/migrations/20260309120007_fix_probe_data_null.go index a7e7366ed..c76d6ed1a 100644 --- a/db/migrations/20260309120007_fix_probe_data_null.go +++ b/db/migrations/20260309120007_fix_probe_data_null.go @@ -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 } diff --git a/db/migrations/20260309203355_ensure_default_transcodings.go b/db/migrations/20260309203355_ensure_default_transcodings.go index ab6d24952..4df66d26d 100644 --- a/db/migrations/20260309203355_ensure_default_transcodings.go +++ b/db/migrations/20260309203355_ensure_default_transcodings.go @@ -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 } diff --git a/db/migrations/20260310113858_fix_aac_transcode_command.go b/db/migrations/20260310113858_fix_aac_transcode_command.go index 588137383..a4fa8fcc1 100644 --- a/db/migrations/20260310113858_fix_aac_transcode_command.go +++ b/db/migrations/20260310113858_fix_aac_transcode_command.go @@ -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 } diff --git a/db/migrations/20260513173954_move_ss_before_input.go b/db/migrations/20260513173954_move_ss_before_input.go index c16583aa0..472ae7b43 100644 --- a/db/migrations/20260513173954_move_ss_before_input.go +++ b/db/migrations/20260513173954_move_ss_before_input.go @@ -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 } } diff --git a/db/migrations/migration.go b/db/migrations/migration.go index fde6f5817..9b1098af1 100644 --- a/db/migrations/migration.go +++ b/db/migrations/migration.go @@ -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 }) From ecba19a08ef5727f2b2a4033b5138ff15d6b867d Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 18 Jun 2026 15:48:29 -0400 Subject: [PATCH 4/4] fix(scanner): resolve symlinks to their target when classifying files The scanner classified a file by the name of the directory entry, so a symlink was treated as audio/image/playlist based on the link name rather than what it actually points to. Now symlinks are fully resolved (following the whole chain) and classified by the resolved target's extension, so a symlink to a non-audio file is no longer imported as a track. This also makes Scanner.FollowSymlinks apply to file symlinks, not just directory symlinks as before. The default stays true, so following symlinks to real audio files (second drives, shared folders, etc.) keeps working. Adds trace logging for symlink resolution decisions and real-fs regression tests covering multi-level symlink chains. --- scanner/walk_dir_tree.go | 49 +++++- scanner/walk_dir_tree_test.go | 211 +++++++++++++++++++++++- tests/fixtures/symlink_chain/evil1.mp3 | 1 + tests/fixtures/symlink_chain/evil2.mp3 | 1 + tests/fixtures/symlink_chain/evil3.mp3 | 1 + tests/fixtures/symlink_chain/level1.mp3 | 1 + tests/fixtures/symlink_chain/level2.mp3 | 1 + tests/fixtures/symlink_chain/level3.mp3 | 1 + 8 files changed, 261 insertions(+), 5 deletions(-) create mode 120000 tests/fixtures/symlink_chain/evil1.mp3 create mode 120000 tests/fixtures/symlink_chain/evil2.mp3 create mode 120000 tests/fixtures/symlink_chain/evil3.mp3 create mode 120000 tests/fixtures/symlink_chain/level1.mp3 create mode 120000 tests/fixtures/symlink_chain/level2.mp3 create mode 120000 tests/fixtures/symlink_chain/level3.mp3 diff --git a/scanner/walk_dir_tree.go b/scanner/walk_dir_tree.go index e6a694f2b..78796ac5f 100644 --- a/scanner/walk_dir_tree.go +++ b/scanner/walk_dir_tree.go @@ -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) diff --git a/scanner/walk_dir_tree_test.go b/scanner/walk_dir_tree_test.go index 42b7af7ba..95cbba88f 100644 --- a/scanner/walk_dir_tree_test.go +++ b/scanner/walk_dir_tree_test.go @@ -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() +} diff --git a/tests/fixtures/symlink_chain/evil1.mp3 b/tests/fixtures/symlink_chain/evil1.mp3 new file mode 120000 index 000000000..79c5d6f02 --- /dev/null +++ b/tests/fixtures/symlink_chain/evil1.mp3 @@ -0,0 +1 @@ +../index.html \ No newline at end of file diff --git a/tests/fixtures/symlink_chain/evil2.mp3 b/tests/fixtures/symlink_chain/evil2.mp3 new file mode 120000 index 000000000..56d18ad24 --- /dev/null +++ b/tests/fixtures/symlink_chain/evil2.mp3 @@ -0,0 +1 @@ +evil1.mp3 \ No newline at end of file diff --git a/tests/fixtures/symlink_chain/evil3.mp3 b/tests/fixtures/symlink_chain/evil3.mp3 new file mode 120000 index 000000000..e1cac02e9 --- /dev/null +++ b/tests/fixtures/symlink_chain/evil3.mp3 @@ -0,0 +1 @@ +evil2.mp3 \ No newline at end of file diff --git a/tests/fixtures/symlink_chain/level1.mp3 b/tests/fixtures/symlink_chain/level1.mp3 new file mode 120000 index 000000000..887033521 --- /dev/null +++ b/tests/fixtures/symlink_chain/level1.mp3 @@ -0,0 +1 @@ +../test.mp3 \ No newline at end of file diff --git a/tests/fixtures/symlink_chain/level2.mp3 b/tests/fixtures/symlink_chain/level2.mp3 new file mode 120000 index 000000000..eca2115ee --- /dev/null +++ b/tests/fixtures/symlink_chain/level2.mp3 @@ -0,0 +1 @@ +level1.mp3 \ No newline at end of file diff --git a/tests/fixtures/symlink_chain/level3.mp3 b/tests/fixtures/symlink_chain/level3.mp3 new file mode 120000 index 000000000..dd72f3cca --- /dev/null +++ b/tests/fixtures/symlink_chain/level3.mp3 @@ -0,0 +1 @@ +level2.mp3 \ No newline at end of file