mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-26 18:27:09 +01:00
feat: add multi-library support to tag repository system
Implemented comprehensive library filtering for tag repositories to support the multi-library feature. This change ensures that users only see tags from libraries they have access to, while admin users can see all tags. Key changes: - Enhanced TagRepository.Add() method to accept libraryID parameter for proper library association - Updated baseTagRepository to implement library-aware queries with proper joins - Added library_tag table integration for per-library tag statistics - Implemented user permission-based filtering through user_library associations - Added comprehensive test coverage for library filtering scenarios - Updated UI data provider to include tag filtering by selected libraries - Modified scanner to pass library ID when adding tags during folder processing The implementation maintains backward compatibility while providing proper isolation between libraries for tag-based operations like genres and other metadata tags.
This commit is contained in:
parent
dd7ae6ce2b
commit
295900e6f7
13 changed files with 348 additions and 49 deletions
|
|
@ -79,6 +79,10 @@ func upAddMultiLibrarySupport(ctx context.Context, tx *sql.Tx) error {
|
|||
SELECT t.id, 1, t.album_count, t.media_file_count
|
||||
FROM tag t
|
||||
WHERE EXISTS (SELECT 1 FROM library WHERE id = 1);
|
||||
|
||||
-- Remove global stats from tag table as they are now per-library
|
||||
ALTER TABLE tag DROP COLUMN album_count;
|
||||
ALTER TABLE tag DROP COLUMN media_file_count;
|
||||
`)
|
||||
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ func (t Tags) Add(name TagName, v string) {
|
|||
}
|
||||
|
||||
type TagRepository interface {
|
||||
Add(...Tag) error
|
||||
Add(libraryID int, tags ...Tag) error
|
||||
UpdateCounts() error
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -730,8 +730,8 @@ var _ = Describe("ArtistRepository", func() {
|
|||
ctx = request.WithUser(ctx, adminUser)
|
||||
adminRepo = NewArtistRepository(ctx, GetDBXBuilder())
|
||||
|
||||
// Create library 2 for testing access restrictions
|
||||
lib2 = model.Library{ID: 0, Name: "Library 2", Path: "/lib2"}
|
||||
// Create library for testing access restrictions
|
||||
lib2 = model.Library{ID: 0, Name: "Artist Test Library", Path: "/artist/test/lib"}
|
||||
lr := NewLibraryRepository(request.WithUser(log.NewContext(context.TODO()), adminUser), GetDBXBuilder())
|
||||
err := lr.Put(&lib2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
|
|
|||
|
|
@ -21,13 +21,7 @@ func NewGenreRepository(ctx context.Context, db dbx.Builder) model.GenreReposito
|
|||
}
|
||||
|
||||
func (r *genreRepository) selectGenre(opt ...model.QueryOptions) SelectBuilder {
|
||||
return r.newSelect(opt...).
|
||||
Columns(
|
||||
"id",
|
||||
"tag_value as name",
|
||||
"album_count",
|
||||
"media_file_count as song_count",
|
||||
)
|
||||
return r.newSelect(opt...)
|
||||
}
|
||||
|
||||
func (r *genreRepository) GetAll(opt ...model.QueryOptions) (model.Genres, error) {
|
||||
|
|
@ -40,7 +34,7 @@ func (r *genreRepository) GetAll(opt ...model.QueryOptions) (model.Genres, error
|
|||
// Override ResourceRepository methods to return Genre objects instead of Tag objects
|
||||
|
||||
func (r *genreRepository) Read(id string) (interface{}, error) {
|
||||
sel := r.selectGenre().Columns("*").Where(Eq{"id": id})
|
||||
sel := r.selectGenre().Where(Eq{"tag.id": id})
|
||||
var res model.Genre
|
||||
err := r.queryOne(sel, &res)
|
||||
return &res, err
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ var _ = Describe("GenreRepository", func() {
|
|||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ctx = request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", UserName: "johndoe"})
|
||||
ctx = request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", UserName: "johndoe", IsAdmin: true})
|
||||
genreRepo := NewGenreRepository(ctx, GetDBXBuilder())
|
||||
repo = genreRepo
|
||||
restRepo = genreRepo.(model.ResourceRepository)
|
||||
|
|
@ -35,12 +35,18 @@ var _ = Describe("GenreRepository", func() {
|
|||
_, err := db.NewQuery("DELETE FROM tag").Execute()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Ensure library 1 exists and user has access to it
|
||||
_, err = db.NewQuery("INSERT OR IGNORE INTO library (id, name, path, default_new_users) VALUES (1, 'Test Library', '/test', true)").Execute()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = db.NewQuery("INSERT OR IGNORE INTO user_library (user_id, library_id) VALUES ('userid', 1)").Execute()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Add comprehensive test data that covers all test scenarios
|
||||
newTag := func(name, value string) model.Tag {
|
||||
return model.Tag{ID: id.NewTagID(name, value), TagName: model.TagName(name), TagValue: value}
|
||||
}
|
||||
|
||||
err = tagRepo.Add(
|
||||
err = tagRepo.Add(1,
|
||||
newTag("genre", "rock"),
|
||||
newTag("genre", "pop"),
|
||||
newTag("genre", "jazz"),
|
||||
|
|
@ -167,7 +173,7 @@ var _ = Describe("GenreRepository", func() {
|
|||
TagName: "mood",
|
||||
TagValue: "energetic",
|
||||
}
|
||||
err := tagRepo.Add(nonGenreTag)
|
||||
err := tagRepo.Add(1, nonGenreTag)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
count, err := restRepo.Count()
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ func userId(ctx context.Context) string {
|
|||
|
||||
func loggedUser(ctx context.Context) *model.User {
|
||||
if user, ok := request.UserFrom(ctx); !ok {
|
||||
return &model.User{}
|
||||
return &model.User{ID: invalidUserId}
|
||||
} else {
|
||||
return &user
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,11 @@ func tagIDFilter(name string, idValue any) Sqlizer {
|
|||
)
|
||||
}
|
||||
|
||||
// tagLibraryIdFilter filters tags based on library access through the library_tag table
|
||||
func tagLibraryIdFilter(_ string, value interface{}) Sqlizer {
|
||||
return Eq{"library_tag.library_id": value}
|
||||
}
|
||||
|
||||
// baseTagRepository provides common functionality for all tag-based repositories.
|
||||
// It handles CRUD operations with optional filtering by tag name.
|
||||
type baseTagRepository struct {
|
||||
|
|
@ -77,7 +82,8 @@ func newBaseTagRepository(ctx context.Context, db dbx.Builder, tagFilter *model.
|
|||
r.db = db
|
||||
r.tableName = "tag"
|
||||
r.registerModel(&model.Tag{}, map[string]filterFunc{
|
||||
"name": containsFilter("tag_value"),
|
||||
"name": containsFilter("tag_value"),
|
||||
"library_id": tagLibraryIdFilter,
|
||||
})
|
||||
r.setSortMappings(map[string]string{
|
||||
"name": "tag_value",
|
||||
|
|
@ -85,19 +91,50 @@ func newBaseTagRepository(ctx context.Context, db dbx.Builder, tagFilter *model.
|
|||
return r
|
||||
}
|
||||
|
||||
// newSelect overrides the base implementation to conditionally apply tag name filtering.
|
||||
// newSelect overrides the base implementation to apply tag name filtering and library filtering.
|
||||
func (r *baseTagRepository) newSelect(options ...model.QueryOptions) SelectBuilder {
|
||||
user := loggedUser(r.ctx)
|
||||
if user.ID == invalidUserId {
|
||||
// No user context - return empty result set
|
||||
return SelectBuilder{}.Where(Eq{"1": "0"})
|
||||
}
|
||||
sq := r.sqlRepository.newSelect(options...)
|
||||
if r.tagFilter != nil {
|
||||
sq = sq.Where(Eq{"tag_name": *r.tagFilter})
|
||||
sq = sq.Where(Eq{"tag.tag_name": *r.tagFilter})
|
||||
}
|
||||
sq = sq.Columns(
|
||||
"tag.id",
|
||||
"tag.tag_value as name",
|
||||
"COALESCE(SUM(library_tag.album_count), 0) as album_count",
|
||||
"COALESCE(SUM(library_tag.media_file_count), 0) as song_count",
|
||||
).
|
||||
LeftJoin("library_tag on library_tag.tag_id = tag.id").
|
||||
// Apply library filtering by joining only with accessible libraries
|
||||
Join("user_library on user_library.library_id = library_tag.library_id AND user_library.user_id = ?", user.ID).
|
||||
GroupBy("tag.id", "tag.tag_value")
|
||||
return sq
|
||||
}
|
||||
|
||||
// ResourceRepository interface implementation
|
||||
|
||||
func (r *baseTagRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.count(r.newSelect(), r.parseRestOptions(r.ctx, options...))
|
||||
// Create a query that counts distinct tags without GROUP BY
|
||||
user := loggedUser(r.ctx)
|
||||
if user.ID == invalidUserId {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Build the same base query as newSelect but for counting
|
||||
sq := Select()
|
||||
if r.tagFilter != nil {
|
||||
sq = sq.Where(Eq{"tag.tag_name": *r.tagFilter})
|
||||
}
|
||||
|
||||
// Apply the same joins as newSelect
|
||||
sq = sq.LeftJoin("library_tag on library_tag.tag_id = tag.id").
|
||||
Join("user_library on user_library.library_id = library_tag.library_id AND user_library.user_id = ?", user.ID)
|
||||
|
||||
return r.count(sq, r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *baseTagRepository) Read(id string) (interface{}, error) {
|
||||
|
|
|
|||
228
persistence/tag_library_filtering_test.go
Normal file
228
persistence/tag_library_filtering_test.go
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
var _ = Describe("Tag Library Filtering", func() {
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
|
||||
// Clean up all relevant tables
|
||||
db := GetDBXBuilder()
|
||||
_, err := db.NewQuery("DELETE FROM library_tag").Execute()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = db.NewQuery("DELETE FROM tag").Execute()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = db.NewQuery("DELETE FROM user_library WHERE user_id != 'userid' AND user_id != '2222'").Execute()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = db.NewQuery("DELETE FROM library WHERE id > 1").Execute()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Create test libraries
|
||||
_, err = db.NewQuery("INSERT INTO library (id, name, path) VALUES (2, 'Library 2', '/music/lib2')").Execute()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = db.NewQuery("INSERT INTO library (id, name, path) VALUES (3, 'Library 3', '/music/lib3')").Execute()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Ensure admin user has access to all libraries (since admin users should have access to all libraries)
|
||||
_, err = db.NewQuery("INSERT OR IGNORE INTO user_library (user_id, library_id) VALUES ('userid', 1)").Execute()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = db.NewQuery("INSERT OR IGNORE INTO user_library (user_id, library_id) VALUES ('userid', 2)").Execute()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = db.NewQuery("INSERT OR IGNORE INTO user_library (user_id, library_id) VALUES ('userid', 3)").Execute()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Set up test tags
|
||||
newTag := func(name, value string) model.Tag {
|
||||
return model.Tag{ID: id.NewTagID(name, value), TagName: model.TagName(name), TagValue: value}
|
||||
}
|
||||
|
||||
// Create tags in admin context
|
||||
adminCtx := request.WithUser(log.NewContext(context.TODO()), adminUser)
|
||||
tagRepo := NewTagRepository(adminCtx, GetDBXBuilder())
|
||||
|
||||
// Add tags to different libraries
|
||||
err = tagRepo.Add(1, newTag("genre", "rock"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = tagRepo.Add(2, newTag("genre", "pop"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = tagRepo.Add(3, newTag("genre", "jazz"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = tagRepo.Add(2, newTag("genre", "rock"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Update counts manually for testing
|
||||
_, err = db.NewQuery("UPDATE library_tag SET album_count = 5, media_file_count = 20 WHERE tag_id = {:tagId} AND library_id = 1").Bind(dbx.Params{"tagId": id.NewTagID("genre", "rock")}).Execute()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = db.NewQuery("UPDATE library_tag SET album_count = 3, media_file_count = 10 WHERE tag_id = {:tagId} AND library_id = 2").Bind(dbx.Params{"tagId": id.NewTagID("genre", "pop")}).Execute()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = db.NewQuery("UPDATE library_tag SET album_count = 2, media_file_count = 8 WHERE tag_id = {:tagId} AND library_id = 3").Bind(dbx.Params{"tagId": id.NewTagID("genre", "jazz")}).Execute()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = db.NewQuery("UPDATE library_tag SET album_count = 1, media_file_count = 4 WHERE tag_id = {:tagId} AND library_id = 2").Bind(dbx.Params{"tagId": id.NewTagID("genre", "rock")}).Execute()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Set up user library access - Regular user has access to libraries 1 and 2 only
|
||||
_, err = db.NewQuery("INSERT INTO user_library (user_id, library_id) VALUES ('2222', 2)").Execute()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
Describe("TagRepository Library Filtering", func() {
|
||||
Context("Admin User", func() {
|
||||
It("should see all tags regardless of library", func() {
|
||||
ctx := request.WithUser(log.NewContext(context.TODO()), adminUser)
|
||||
tagRepo := NewTagRepository(ctx, GetDBXBuilder())
|
||||
repo := tagRepo.(model.ResourceRepository)
|
||||
|
||||
tags, err := repo.ReadAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
tagList := tags.(model.TagList)
|
||||
Expect(tagList).To(HaveLen(3))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Regular User with Limited Library Access", func() {
|
||||
It("should only see tags from accessible libraries", func() {
|
||||
ctx := request.WithUser(log.NewContext(context.TODO()), regularUser)
|
||||
tagRepo := NewTagRepository(ctx, GetDBXBuilder())
|
||||
repo := tagRepo.(model.ResourceRepository)
|
||||
|
||||
tags, err := repo.ReadAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
tagList := tags.(model.TagList)
|
||||
|
||||
// Should see rock (libraries 1,2) and pop (library 2), but not jazz (library 3)
|
||||
Expect(tagList).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("should respect explicit library_id filters within accessible libraries", func() {
|
||||
ctx := request.WithUser(log.NewContext(context.TODO()), regularUser)
|
||||
tagRepo := NewTagRepository(ctx, GetDBXBuilder())
|
||||
repo := tagRepo.(model.ResourceRepository)
|
||||
|
||||
// Filter by library 2 (user has access to libraries 1 and 2)
|
||||
tags, err := repo.ReadAll(rest.QueryOptions{
|
||||
Filters: map[string]interface{}{
|
||||
"library_id": 2,
|
||||
},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
tagList := tags.(model.TagList)
|
||||
|
||||
// Should see only tags from library 2: pop and rock(lib2)
|
||||
Expect(tagList).To(HaveLen(2))
|
||||
|
||||
// Verify the tags are correct
|
||||
tagValues := make([]string, len(tagList))
|
||||
for i, tag := range tagList {
|
||||
tagValues[i] = tag.TagValue
|
||||
}
|
||||
Expect(tagValues).To(ContainElements("pop", "rock"))
|
||||
})
|
||||
|
||||
It("should not return tags when filtering by inaccessible library", func() {
|
||||
ctx := request.WithUser(log.NewContext(context.TODO()), regularUser)
|
||||
tagRepo := NewTagRepository(ctx, GetDBXBuilder())
|
||||
repo := tagRepo.(model.ResourceRepository)
|
||||
|
||||
// Try to filter by library 3 (user doesn't have access)
|
||||
tags, err := repo.ReadAll(rest.QueryOptions{
|
||||
Filters: map[string]interface{}{
|
||||
"library_id": 3,
|
||||
},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
tagList := tags.(model.TagList)
|
||||
|
||||
// Should return no tags since user can't access library 3
|
||||
Expect(tagList).To(HaveLen(0))
|
||||
})
|
||||
|
||||
It("should filter by library 1 correctly", func() {
|
||||
ctx := request.WithUser(log.NewContext(context.TODO()), regularUser)
|
||||
tagRepo := NewTagRepository(ctx, GetDBXBuilder())
|
||||
repo := tagRepo.(model.ResourceRepository)
|
||||
|
||||
// Filter by library 1 (user has access)
|
||||
tags, err := repo.ReadAll(rest.QueryOptions{
|
||||
Filters: map[string]interface{}{
|
||||
"library_id": 1,
|
||||
},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
tagList := tags.(model.TagList)
|
||||
|
||||
// Should see only rock from library 1
|
||||
Expect(tagList).To(HaveLen(1))
|
||||
Expect(tagList[0].TagValue).To(Equal("rock"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Admin User with Explicit Library Filtering", func() {
|
||||
It("should see all tags when no filter is applied", func() {
|
||||
adminCtx := request.WithUser(log.NewContext(context.TODO()), adminUser)
|
||||
tagRepo := NewTagRepository(adminCtx, GetDBXBuilder())
|
||||
repo := tagRepo.(model.ResourceRepository)
|
||||
|
||||
tags, err := repo.ReadAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
tagList := tags.(model.TagList)
|
||||
Expect(tagList).To(HaveLen(3))
|
||||
})
|
||||
|
||||
It("should respect explicit library_id filters", func() {
|
||||
adminCtx := request.WithUser(log.NewContext(context.TODO()), adminUser)
|
||||
tagRepo := NewTagRepository(adminCtx, GetDBXBuilder())
|
||||
repo := tagRepo.(model.ResourceRepository)
|
||||
|
||||
// Filter by library 3
|
||||
tags, err := repo.ReadAll(rest.QueryOptions{
|
||||
Filters: map[string]interface{}{
|
||||
"library_id": 3,
|
||||
},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
tagList := tags.(model.TagList)
|
||||
|
||||
// Should see only jazz from library 3
|
||||
Expect(tagList).To(HaveLen(1))
|
||||
Expect(tagList[0].TagValue).To(Equal("jazz"))
|
||||
})
|
||||
|
||||
It("should filter by library 2 correctly", func() {
|
||||
adminCtx := request.WithUser(log.NewContext(context.TODO()), adminUser)
|
||||
tagRepo := NewTagRepository(adminCtx, GetDBXBuilder())
|
||||
repo := tagRepo.(model.ResourceRepository)
|
||||
|
||||
// Filter by library 2
|
||||
tags, err := repo.ReadAll(rest.QueryOptions{
|
||||
Filters: map[string]interface{}{
|
||||
"library_id": 2,
|
||||
},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
tagList := tags.(model.TagList)
|
||||
|
||||
// Should see pop and rock from library 2
|
||||
Expect(tagList).To(HaveLen(2))
|
||||
|
||||
tagValues := make([]string, len(tagList))
|
||||
for i, tag := range tagList {
|
||||
tagValues[i] = tag.TagValue
|
||||
}
|
||||
Expect(tagValues).To(ContainElements("pop", "rock"))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -22,7 +22,7 @@ func NewTagRepository(ctx context.Context, db dbx.Builder) model.TagRepository {
|
|||
}
|
||||
}
|
||||
|
||||
func (r *tagRepository) Add(tags ...model.Tag) error {
|
||||
func (r *tagRepository) Add(libraryID int, tags ...model.Tag) error {
|
||||
for chunk := range slices.Chunk(tags, 200) {
|
||||
sq := Insert(r.tableName).Columns("id", "tag_name", "tag_value").
|
||||
Suffix("on conflict (id) do nothing")
|
||||
|
|
@ -33,34 +33,41 @@ func (r *tagRepository) Add(tags ...model.Tag) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create library_tag entries for library filtering
|
||||
libSq := Insert("library_tag").Columns("tag_id", "library_id", "album_count", "media_file_count").
|
||||
Suffix("on conflict (tag_id, library_id) do nothing")
|
||||
for _, t := range chunk {
|
||||
libSq = libSq.Values(t.ID, libraryID, 0, 0)
|
||||
}
|
||||
_, err = r.executeSQL(libSq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("adding library_tag entries: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateCounts updates the album_count and media_file_count columns in the tag_counts table.
|
||||
// UpdateCounts updates the library_tag table with per-library statistics.
|
||||
// Only genres are being updated for now.
|
||||
func (r *tagRepository) UpdateCounts() error {
|
||||
template := `
|
||||
with updated_values as (
|
||||
select jt.value as id, count(distinct %[1]s.id) as %[1]s_count
|
||||
from %[1]s
|
||||
join json_tree(tags, '$.genre') as jt
|
||||
where atom is not null
|
||||
and key = 'id'
|
||||
group by jt.value
|
||||
)
|
||||
update tag
|
||||
set %[1]s_count = updated_values.%[1]s_count
|
||||
from updated_values
|
||||
where tag.id = updated_values.id;
|
||||
INSERT INTO library_tag (tag_id, library_id, %[1]s_count)
|
||||
SELECT jt.value as tag_id, %[1]s.library_id, count(distinct %[1]s.id) as %[1]s_count
|
||||
FROM %[1]s
|
||||
JOIN json_tree(%[1]s.tags, '$.genre') as jt ON jt.atom IS NOT NULL AND jt.key = 'id'
|
||||
GROUP BY jt.value, %[1]s.library_id
|
||||
ON CONFLICT (tag_id, library_id)
|
||||
DO UPDATE SET %[1]s_count = excluded.%[1]s_count;
|
||||
`
|
||||
|
||||
for _, table := range []string{"album", "media_file"} {
|
||||
start := time.Now()
|
||||
query := Expr(fmt.Sprintf(template, table))
|
||||
c, err := r.executeSQL(query)
|
||||
log.Debug(r.ctx, "Updated tag counts", "table", table, "elapsed", time.Since(start), "updated", c)
|
||||
log.Debug(r.ctx, "Updated library tag counts", "table", table, "elapsed", time.Since(start), "updated", c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("updating %s tag counts: %w", table, err)
|
||||
return fmt.Errorf("updating %s library tag counts: %w", table, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
|
@ -70,6 +77,11 @@ func (r *tagRepository) purgeUnused() error {
|
|||
del := Delete(r.tableName).Where(`
|
||||
id not in (select jt.value
|
||||
from album left join json_tree(album.tags, '$') as jt
|
||||
where atom is not null
|
||||
and key = 'id'
|
||||
UNION
|
||||
select jt.value
|
||||
from media_file left join json_tree(media_file.tags, '$') as jt
|
||||
where atom is not null
|
||||
and key = 'id')
|
||||
`)
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ var _ = Describe("TagRepository", func() {
|
|||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ctx = request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", UserName: "johndoe"})
|
||||
ctx = request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", UserName: "johndoe", IsAdmin: true})
|
||||
tagRepo := NewTagRepository(ctx, GetDBXBuilder())
|
||||
repo = tagRepo
|
||||
restRepo = tagRepo.(model.ResourceRepository)
|
||||
|
|
@ -31,13 +31,23 @@ var _ = Describe("TagRepository", func() {
|
|||
db := GetDBXBuilder()
|
||||
_, err := db.NewQuery("DELETE FROM tag").Execute()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = db.NewQuery("DELETE FROM library_tag").Execute()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Ensure library 1 exists (if it doesn't already)
|
||||
_, err = db.NewQuery("INSERT OR IGNORE INTO library (id, name, path, default_new_users) VALUES (1, 'Test Library', '/test', true)").Execute()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Ensure the admin user has access to library 1
|
||||
_, err = db.NewQuery("INSERT OR IGNORE INTO user_library (user_id, library_id) VALUES ('userid', 1)").Execute()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Add comprehensive test data that covers all test scenarios
|
||||
newTag := func(name, value string) model.Tag {
|
||||
return model.Tag{ID: id.NewTagID(name, value), TagName: model.TagName(name), TagValue: value}
|
||||
}
|
||||
|
||||
err = repo.Add(
|
||||
err = repo.Add(1,
|
||||
// Genre tags
|
||||
newTag("genre", "rock"),
|
||||
newTag("genre", "pop"),
|
||||
|
|
@ -73,7 +83,7 @@ var _ = Describe("TagRepository", func() {
|
|||
TagValue: "experimental",
|
||||
}
|
||||
|
||||
err := repo.Add(newTag)
|
||||
err := repo.Add(1, newTag)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Verify tag was added
|
||||
|
|
@ -100,7 +110,7 @@ var _ = Describe("TagRepository", func() {
|
|||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(Equal(int64(20))) // Still 20 tags
|
||||
|
||||
err = repo.Add(duplicateTag)
|
||||
err = repo.Add(1, duplicateTag)
|
||||
Expect(err).ToNot(HaveOccurred()) // Should not error
|
||||
|
||||
// Count should remain the same
|
||||
|
|
|
|||
|
|
@ -336,13 +336,20 @@ var _ = Describe("UserRepository", func() {
|
|||
|
||||
Describe("Admin User Auto-Assignment", func() {
|
||||
var (
|
||||
libRepo model.LibraryRepository
|
||||
library1 model.Library
|
||||
library2 model.Library
|
||||
libRepo model.LibraryRepository
|
||||
library1 model.Library
|
||||
library2 model.Library
|
||||
initialLibCount int
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
libRepo = NewLibraryRepository(log.NewContext(context.TODO()), GetDBXBuilder())
|
||||
|
||||
// Count initial libraries
|
||||
existingLibs, err := libRepo.GetAll()
|
||||
Expect(err).To(BeNil())
|
||||
initialLibCount = len(existingLibs)
|
||||
|
||||
library1 = model.Library{ID: 0, Name: "Admin Test Library 1", Path: "/admin/test/path1"}
|
||||
library2 = model.Library{ID: 0, Name: "Admin Test Library 2", Path: "/admin/test/path2"}
|
||||
|
||||
|
|
@ -375,13 +382,13 @@ var _ = Describe("UserRepository", func() {
|
|||
// Admin should automatically have access to all libraries (including existing ones)
|
||||
libraries, err := repo.GetUserLibraries(adminUser.ID)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(libraries).To(HaveLen(3)) // Our 2 test libraries + existing library ID 1
|
||||
Expect(libraries).To(HaveLen(initialLibCount + 2)) // Initial libraries + our 2 test libraries
|
||||
|
||||
libIDs := make([]int, len(libraries))
|
||||
for i, lib := range libraries {
|
||||
libIDs[i] = lib.ID
|
||||
}
|
||||
Expect(libIDs).To(ContainElements(library1.ID, library2.ID, 1))
|
||||
Expect(libIDs).To(ContainElements(library1.ID, library2.ID))
|
||||
})
|
||||
|
||||
It("automatically assigns all libraries to admin users when updated", func() {
|
||||
|
|
@ -410,14 +417,14 @@ var _ = Describe("UserRepository", func() {
|
|||
// Should now have access to all libraries (including existing ones)
|
||||
libraries, err := repo.GetUserLibraries(regularUser.ID)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(libraries).To(HaveLen(3)) // Our 2 test libraries + existing library ID 1
|
||||
Expect(libraries).To(HaveLen(initialLibCount + 2)) // Initial libraries + our 2 test libraries
|
||||
|
||||
libIDs := make([]int, len(libraries))
|
||||
for i, lib := range libraries {
|
||||
libIDs[i] = lib.ID
|
||||
}
|
||||
// Should include our test libraries plus all existing ones
|
||||
Expect(libIDs).To(ContainElements(library1.ID, library2.ID, 1))
|
||||
Expect(libIDs).To(ContainElements(library1.ID, library2.ID))
|
||||
})
|
||||
|
||||
It("assigns default libraries to regular users", func() {
|
||||
|
|
|
|||
|
|
@ -342,7 +342,7 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
|
|||
}
|
||||
|
||||
// Save all tags to DB
|
||||
err = tagRepo.Add(entry.tags...)
|
||||
err = tagRepo.Add(entry.job.lib.ID, entry.tags...)
|
||||
if err != nil {
|
||||
log.Error(p.ctx, "Scanner: Error persisting tags to DB", "folder", entry.path, err)
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const getSelectedLibraries = () => {
|
|||
// Function to apply library filtering to appropriate resources
|
||||
const applyLibraryFilter = (resource, params) => {
|
||||
// Content resources that should be filtered by selected libraries
|
||||
const filteredResources = ['album', 'song', 'artist', 'playlistTrack']
|
||||
const filteredResources = ['album', 'song', 'artist', 'playlistTrack', 'tag']
|
||||
|
||||
// Get selected libraries from localStorage
|
||||
const selectedLibraries = getSelectedLibraries()
|
||||
|
|
@ -54,7 +54,8 @@ const mapResource = (resource, params) => {
|
|||
}
|
||||
case 'album':
|
||||
case 'song':
|
||||
case 'artist': {
|
||||
case 'artist':
|
||||
case 'tag': {
|
||||
params.filter = params.filter || {}
|
||||
if (!isAdmin()) {
|
||||
params.filter.missing = false
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue