mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 18:37:09 +02:00
Replace the 131 SQLite migrations with a single goose migration that creates the whole schema in PostgreSQL (the user table becomes user_account, json columns become jsonb, seed rows for the default library and transcodings). Port the repositories' SQL just far enough for Navidrome to start, scan, log in and browse artists and albums: json_agg/jsonb_* for the SQLite JSON functions, lower() for COLLATE NOCASE, ON CONFLICT DO NOTHING, coalesce on annotations, stricter GROUP BY, explicit casts under the simple protocol, sequential queries in library.RefreshStats. db/ is now Postgres-only, with backup/restore stubbed and DevExternalScanner forced off. Embed the PGlite module like go-taglib: pglite.wasi.gz is go:embed'ed, decompressed in memory and compiled by wazero with an on-disk compilation cache, so the module itself is never written to disk. The cluster is created by initdb at runtime on the first start; initdb runs in a throwaway wasm instance and the backend in a fresh one, otherwise the next start cannot find a valid checkpoint. Since initdb creates only template1, a short-lived backend then runs CREATE DATABASE navidrome and the real backend starts on it. The build recipe and patches for the -O2, no-wizer module live in db/pglite/build.
206 lines
6.3 KiB
Go
206 lines
6.3 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
|
|
}
|
|
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("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
|
|
}
|