package persistence
import (
"context"
"encoding/json"
"fmt"
"strings"
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/model"
"github.com/pocketbase/dbx"
)
type dbTag struct {
ID string `json:"id"`
Value string `json:"value"`
}
type dbTags map[model.TagName][]dbTag
func unmarshalTags(data string) (model.Tags, error) {
var dbTags dbTags
err := json.Unmarshal([]byte(data), &dbTags)
if err != nil {
return nil, fmt.Errorf("parsing tags: %w", err)
}
res := make(model.Tags, len(dbTags))
for name, tags := range dbTags {
res[name] = make([]string, len(tags))
for i, tag := range tags {
res[name][i] = tag.Value
}
}
return res, nil
}
func marshalTags(tags model.Tags) string {
dbTags := dbTags{}
for name, values := range tags {
for _, value := range values {
t := model.NewTag(name, value)
dbTags[name] = append(dbTags[name], dbTag{ID: t.ID, Value: value})
}
}
res, _ := json.Marshal(dbTags)
return string(res)
}
var indexedTagNames = []model.TagName{model.TagGenre}
func (r sqlRepository) updateTags(ctx context.Context, itemID string, tags model.Tags) error {
del := Delete(r.tableName + "_tags").Where(Eq{r.tableName + "_id": itemID})
if _, err := r.executeSQL(ctx, del); err != nil {
return err
}
var tagIDs []string
for _, name := range indexedTagNames {
for _, value := range tags.Values(name) {
tagIDs = append(tagIDs, model.NewTag(name, value).ID)
}
}
if len(tagIDs) == 0 {
return nil
}
idsJSON, err := json.Marshal(tagIDs)
if err != nil {
return fmt.Errorf("marshaling tag ids: %w", err)
}
query := fmt.Sprintf(`
INSERT INTO %[1]s_tags (%[1]s_id, tag_id)
SELECT ?, value FROM json_each(?)
JOIN tag ON tag.id = value
ON CONFLICT (%[1]s_id, tag_id) DO NOTHING`, r.tableName)
_, err = r.executeSQL(ctx, Expr(query, itemID, string(idsJSON)))
return err
}
type genreFilterDef struct{ idCol, table, joinCol string }
var (
SongGenres = genreFilterDef{"media_file.id", "media_file_tags", "media_file_id"}
AlbumGenres = genreFilterDef{"album.id", "album_tags", "album_id"}
)
func (g genreFilterDef) ByID(tagIDs any) Sqlizer {
sub, args, _ := Select(g.joinCol).From(g.table).Where(Eq{"tag_id": tagIDs}).ToSql()
return Expr(g.idCol+" IN ("+sub+")", args...)
}
func (g genreFilterDef) ByName(genre string) Sqlizer {
sub, args, _ := Select("jt." + g.joinCol).From(g.table + " jt").
Join("tag on tag.id = jt.tag_id").
Where(And{Eq{"tag.tag_name": "genre"}, Like{"tag.tag_value": genre}}).ToSql()
return Expr(g.idCol+" IN ("+sub+")", args...)
}
func AlbumArtistsByGenreID(tagIDs []string) Sqlizer {
sub, args, _ := Select("aa.artist_id").From("album_artists aa").
Join("album_tags at on at.album_id = aa.album_id").
Where(And{Eq{"aa.role": "albumartist"}, Eq{"at.tag_id": tagIDs}}).ToSql()
return Expr("artist.id IN ("+sub+")", args...)
}
func genreFilter(filter genreFilterDef) func(_ string, v any) Sqlizer {
return func(_ string, v any) Sqlizer {
return filter.ByID(v)
}
}
func tagIDFilter(name string, idValue any) Sqlizer {
name = strings.TrimSuffix(name, "_id")
return Exists(
fmt.Sprintf(`json_tree(tags, "$.%s")`, name),
And{
NotEq{"json_tree.atom": nil},
Eq{"value": idValue},
},
)
}
func tagLibraryIdFilter(_ string, value any) Sqlizer {
return Eq{"library_tag.library_id": value}
}
type baseTagRepository struct {
sqlRepository
tagFilter *model.TagName
}
func newBaseTagRepository(db dbx.Builder, tagFilter *model.TagName) *baseTagRepository {
r := &baseTagRepository{
tagFilter: tagFilter,
}
r.db = db
r.tableName = "tag"
r.registerModel(&model.Tag{}, map[string]filterFunc{
"name": containsFilter("tag_value"),
"library_id": tagLibraryIdFilter,
})
r.setSortMappings(map[string]string{
"name": "tag_value",
})
return r
}
func (r *baseTagRepository) applyLibraryFiltering(ctx context.Context, sq SelectBuilder) SelectBuilder {
sq = sq.LeftJoin("library_tag on library_tag.tag_id = tag.id")
user := loggedUser(ctx)
if user.ID != invalidUserId {
sq = sq.Join("user_library on user_library.library_id = library_tag.library_id AND user_library.user_id = ?", user.ID)
}
return sq
}
func (r *baseTagRepository) newSelect(ctx context.Context, options ...model.QueryOptions) SelectBuilder {
sq := r.sqlRepository.newSelect(ctx, options...)
if r.tagFilter != nil {
sq = sq.Where(Eq{"tag.tag_name": *r.tagFilter})
}
sq = r.applyLibraryFiltering(ctx, sq).Columns(
"tag.id",
"tag.tag_name",
"tag.tag_value",
"COALESCE(SUM(library_tag.album_count), 0) as album_count",
"COALESCE(SUM(library_tag.media_file_count), 0) as song_count",
).GroupBy("tag.id", "tag.tag_name", "tag.tag_value")
return sq
}
func (r *baseTagRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) {
sq := Select("COUNT(DISTINCT tag.id)").From("tag")
if r.tagFilter != nil {
sq = sq.Where(Eq{"tag.tag_name": *r.tagFilter})
}
sq = r.applyLibraryFiltering(ctx, sq)
return r.count(ctx, sq, r.parseRestOptions(ctx, options...))
}
func (r *baseTagRepository) Read(ctx context.Context, id string) (*model.Tag, error) {
query := r.newSelect(ctx).Where(Eq{"id": id})
var res model.Tag
err := r.queryOne(ctx, query, &res)
return &res, err
}
func (r *baseTagRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Tag, error) {
query := r.newSelect(ctx, r.parseRestOptions(ctx, options...))
var res model.TagList
err := r.queryAll(ctx, query, &res)
return res, err
}
var _ rest.Repository[model.Tag] = (*baseTagRepository)(nil)