navidrome/persistence/sql_annotations.go
Deluan 385bab35fe fix(persistence): recompute the cached average rating inside ReassignAnnotation
Moving annotation rows always changes the new item's rating population, so
the recompute belongs with the move rather than at each call site. Covers
the album reassign in the remap and the two scanner sites, and replaces the
explicit call ReassignReferences was making.

Album was the worse case: rate an album, move its files, and 'missing fix'
handed the rating to an album still caching an average of 0.
2026-09-12 11:01:26 -04:00

209 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, 0) 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
}
if strings.ToLower(v) == "true" {
return Expr(fmt.Sprintf("COALESCE(%s, 0) > 0", field))
}
return Expr(fmt.Sprintf("COALESCE(%s, 0) = 0", field))
}
}
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("max(ifnull(play_date,''),?)", 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
}
// OR IGNORE keeps newID's own row where a user annotated both, instead of aborting the whole statement
upd := Expr("update or ignore "+annotationTable+" set item_id = ? where item_type = ? and item_id = ?",
newID, r.tableName, prevID)
if _, err := r.executeSQL(upd); err != nil {
return err
}
// The moved rows change newID's rating population, so its cached average no longer matches
return r.updateAvgRating(newID)
}
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
}