mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 10:27:08 +02:00
annotationBoolFilter serves both starred (boolean) and rating (integer); the boolean form broke the Top Rated lists with 'COALESCE types integer and boolean cannot be matched'.
210 lines
6.4 KiB
Go
210 lines
6.4 KiB
Go
package persistence
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
. "github.com/Masterminds/squirrel"
|
|
"github.com/fatih/structs"
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/consts"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
)
|
|
|
|
const annotationTable = "annotation"
|
|
|
|
// annotationColumns are the columns withAnnotation's LEFT JOIN contributes, derived from
|
|
// model.Annotations so the set tracks schema changes. average_rating is excluded: it lives on the
|
|
// base table, not the annotation join.
|
|
var annotationColumns = sync.OnceValue(func() map[string]struct{} {
|
|
cols := map[string]struct{}{}
|
|
for name := range structs.Map(model.Annotations{}) {
|
|
if name == "average_rating" {
|
|
continue
|
|
}
|
|
cols[name] = struct{}{}
|
|
}
|
|
return cols
|
|
})
|
|
|
|
// annotationColumnRE matches any annotation column as a whole word. The word boundaries keep the
|
|
// base-table column average_rating from matching the annotation column rating (Go's \b treats '_'
|
|
// as a word char). It is case-insensitive because SQLite column names are, so a raw filter using
|
|
// e.g. "RATING" must still be detected.
|
|
var annotationColumnRE = sync.OnceValue(func() *regexp.Regexp {
|
|
cols := make([]string, 0, len(annotationColumns()))
|
|
for col := range annotationColumns() {
|
|
cols = append(cols, regexp.QuoteMeta(col))
|
|
}
|
|
sort.Strings(cols) // map iteration is random; sort for a stable pattern
|
|
return regexp.MustCompile(`(?i)\b(?:` + strings.Join(cols, "|") + `)\b`)
|
|
})
|
|
|
|
// filtersNeedAnnotation reports whether the rendered query references an annotation column, i.e.
|
|
// whether the annotation LEFT JOIN must be kept. Scanning the rendered SQL catches every filter
|
|
// path. The placeholder column is needed because squirrel won't render a column-less SELECT; on a
|
|
// render error, keep the join to be safe.
|
|
func filtersNeedAnnotation(query SelectBuilder) bool {
|
|
sql, _, err := query.Columns("1").ToSql()
|
|
if err != nil {
|
|
return true
|
|
}
|
|
return annotationColumnRE().MatchString(sql)
|
|
}
|
|
|
|
func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) SelectBuilder {
|
|
userID := loggedUser(r.ctx).ID
|
|
if userID == invalidUserId {
|
|
return query.Columns(fmt.Sprintf("%s.average_rating", r.tableName))
|
|
}
|
|
query = query.
|
|
LeftJoin("annotation on ("+
|
|
"annotation.item_id = "+idField+
|
|
" AND annotation.item_type = ?"+
|
|
" AND annotation.user_id = ?)", r.tableName, userID).
|
|
Columns(
|
|
"coalesce(starred, false) as starred",
|
|
"coalesce(rating, 0) as rating",
|
|
"starred_at",
|
|
"play_date",
|
|
"rated_at",
|
|
)
|
|
if conf.Server.AlbumPlayCountMode == consts.AlbumPlayCountModeNormalized && r.tableName == "album" {
|
|
query = query.Columns(
|
|
fmt.Sprintf("round(coalesce(round(cast(play_count as float) / coalesce(%[1]s.song_count, 1), 1), 0)) as play_count", r.tableName),
|
|
)
|
|
} else {
|
|
query = query.Columns("coalesce(play_count, 0) as play_count")
|
|
}
|
|
|
|
query = query.Columns(fmt.Sprintf("%s.average_rating", r.tableName))
|
|
|
|
return query
|
|
}
|
|
|
|
func annotationBoolFilter(field string) func(string, any) Sqlizer {
|
|
return func(_ string, value any) Sqlizer {
|
|
v, ok := value.(string)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
set := strings.ToLower(v) == "true"
|
|
if field == "rating" { // integer column; starred is a boolean
|
|
if set {
|
|
return Expr("COALESCE(rating, 0) > 0")
|
|
}
|
|
return Expr("COALESCE(rating, 0) = 0")
|
|
}
|
|
return Expr(fmt.Sprintf("COALESCE(%s, false) = %t", field, set))
|
|
}
|
|
}
|
|
|
|
func (r sqlRepository) annId(itemID ...string) And {
|
|
userID := loggedUser(r.ctx).ID
|
|
return And{
|
|
Eq{annotationTable + ".user_id": userID},
|
|
Eq{annotationTable + ".item_type": r.tableName},
|
|
Eq{annotationTable + ".item_id": itemID},
|
|
}
|
|
}
|
|
|
|
func (r sqlRepository) annUpsert(values map[string]any, itemIDs ...string) error {
|
|
upd := Update(annotationTable).Where(r.annId(itemIDs...))
|
|
for f, v := range values {
|
|
upd = upd.Set(f, v)
|
|
}
|
|
c, err := r.executeSQL(upd)
|
|
if c == 0 || errors.Is(err, sql.ErrNoRows) {
|
|
userID := loggedUser(r.ctx).ID
|
|
for _, itemID := range itemIDs {
|
|
values["user_id"] = userID
|
|
values["item_type"] = r.tableName
|
|
values["item_id"] = itemID
|
|
ins := Insert(annotationTable).SetMap(values)
|
|
_, err = r.executeSQL(ins)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (r sqlRepository) SetStar(starred bool, ids ...string) error {
|
|
starredAt := time.Now()
|
|
return r.annUpsert(map[string]any{"starred": starred, "starred_at": starredAt}, ids...)
|
|
}
|
|
|
|
func (r sqlRepository) SetRating(rating int, itemID string) error {
|
|
ratedAt := time.Now()
|
|
err := r.annUpsert(map[string]any{"rating": rating, "rated_at": ratedAt}, itemID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return r.updateAvgRating(itemID)
|
|
}
|
|
|
|
func (r sqlRepository) updateAvgRating(itemID string) error {
|
|
upd := Update(r.tableName).
|
|
Where(Eq{"id": itemID}).
|
|
Set("average_rating", Expr(
|
|
"coalesce((select round(avg(rating), 2) from annotation where item_id = ? and item_type = ? and rating > 0), 0)",
|
|
itemID, r.tableName,
|
|
))
|
|
_, err := r.executeSQL(upd)
|
|
return err
|
|
}
|
|
|
|
func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error {
|
|
upd := Update(annotationTable).Where(r.annId(itemID)).
|
|
Set("play_count", Expr("play_count+1")).
|
|
Set("play_date", Expr("greatest(coalesce(play_date, '0001-01-01'::timestamp), ?)", ts))
|
|
c, err := r.executeSQL(upd)
|
|
|
|
if c == 0 || errors.Is(err, sql.ErrNoRows) {
|
|
userID := loggedUser(r.ctx).ID
|
|
values := map[string]any{}
|
|
values["user_id"] = userID
|
|
values["item_type"] = r.tableName
|
|
values["item_id"] = itemID
|
|
values["play_count"] = 1
|
|
values["play_date"] = ts
|
|
ins := Insert(annotationTable).SetMap(values)
|
|
_, err = r.executeSQL(ins)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (r sqlRepository) ReassignAnnotation(prevID string, newID string) error {
|
|
if prevID == newID || prevID == "" || newID == "" {
|
|
return nil
|
|
}
|
|
upd := Update(annotationTable).Where(And{
|
|
Eq{annotationTable + ".item_type": r.tableName},
|
|
Eq{annotationTable + ".item_id": prevID},
|
|
}).Set("item_id", newID)
|
|
_, err := r.executeSQL(upd)
|
|
return err
|
|
}
|
|
|
|
func (r sqlRepository) cleanAnnotations() error {
|
|
del := Delete(annotationTable).Where(Eq{"item_type": r.tableName}).Where("item_id not in (select id from " + r.tableName + ")")
|
|
c, err := r.executeSQL(del)
|
|
if err != nil {
|
|
return fmt.Errorf("error cleaning up %s annotations: %w", r.tableName, err)
|
|
}
|
|
if c > 0 {
|
|
log.Debug(r.ctx, "Clean-up annotations", "table", r.tableName, "totalDeleted", c)
|
|
}
|
|
return nil
|
|
}
|