package persistence
import (
"time"
. "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
usr := loggedUser(r.ctx)
if !r.shouldRefreshSmartPlaylist(pls, usr) {
return false
}
log.Debug(r.ctx, "Refreshing smart playlist", "playlist", pls.Name, "id", pls.ID)
start := time.Now()
del := Delete("playlist_tracks").Where(Eq{"playlist_id": pls.ID})
if _, err := r.executeSQL(del); err != nil {
log.Error(r.ctx, "Error deleting old smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err)
return false
}
rulesSQL := newSmartPlaylistCriteria(*pls.Rules, withSmartPlaylistOwner(*usr))
if !r.refreshChildPlaylists(pls, rulesSQL) {
return false
}
if err := r.resolvePercentageLimit(pls, &rulesSQL, usr.ID); err != nil {
return false
}
sq := r.buildSmartPlaylistQuery(pls, rulesSQL, usr.ID)
sq, err := r.addCriteria(sq, rulesSQL)
if err != nil {
log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err)
return false
}
insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq)
if _, err = r.executeSQL(insSql); err != nil {
log.Error(r.ctx, "Error refreshing smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err)
return false
}
if err = r.refreshCounters(pls); err != nil {
log.Error(r.ctx, "Error updating smart playlist stats", "playlist", pls.Name, "id", pls.ID, err)
return false
}
now := time.Now()
updSql := Update(r.tableName).Set("evaluated_at", now).Where(Eq{"id": pls.ID})
if _, err = r.executeSQL(updSql); err != nil {
log.Error(r.ctx, "Error updating smart playlist", "playlist", pls.Name, "id", pls.ID, err)
return false
}
pls.EvaluatedAt = &now
log.Debug(r.ctx, "Refreshed playlist", "playlist", pls.Name, "id", pls.ID, "numTracks", pls.SongCount, "elapsed", time.Since(start))
return true
}
func (r *playlistRepository) shouldRefreshSmartPlaylist(pls *model.Playlist, usr *model.User) bool {
if !pls.IsSmartPlaylist() {
return false
}
if pls.EvaluatedAt != nil && time.Since(*pls.EvaluatedAt) < conf.Server.SmartPlaylistRefreshDelay {
return false
}
if pls.OwnerID != usr.ID {
log.Trace(r.ctx, "Not refreshing smart playlist from other user", "playlist", pls.Name, "id", pls.ID)
return false
}
return true
}
func (r *playlistRepository) refreshChildPlaylists(pls *model.Playlist, rulesSQL smartPlaylistCriteria) bool {
childPlaylistIds := rulesSQL.ChildPlaylistIds()
if len(childPlaylistIds) == 0 {
return true
}
childPlaylists, err := r.GetAll(model.QueryOptions{Filters: Eq{"playlist.id": childPlaylistIds}})
if err != nil {
log.Error(r.ctx, "Error loading child playlists for smart playlist refresh", "playlist", pls.Name, "id", pls.ID, "childIds", childPlaylistIds, err)
return false
}
found := make(map[string]struct{}, len(childPlaylists))
for i := range childPlaylists {
found[childPlaylists[i].ID] = struct{}{}
r.refreshSmartPlaylist(&childPlaylists[i])
}
for _, id := range childPlaylistIds {
if _, ok := found[id]; !ok {
log.Warn(r.ctx, "Referenced playlist is not accessible to smart playlist owner", "playlist", pls.Name, "id", pls.ID, "childId", id, "ownerId", pls.OwnerID)
}
}
return true
}
func (r *playlistRepository) resolvePercentageLimit(pls *model.Playlist, rulesSQL *smartPlaylistCriteria, userID string) error {
if !rulesSQL.IsPercentageLimit() {
return nil
}
exprJoins := rulesSQL.ExpressionJoins()
countSq := Select("count(*) as count").From("media_file")
countSq = r.addMediaFileAnnotationJoin(countSq, userID)
countSq = r.addSmartPlaylistAnnotationJoins(countSq, exprJoins, userID)
countSq = r.applyLibraryFilter(countSq, "media_file")
cond, err := rulesSQL.Where()
if err != nil {
log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err)
return err
}
countSq = countSq.Where(cond)
var res struct{ Count int64 }
if err = r.queryOne(countSq, &res); err != nil {
log.Error(r.ctx, "Error counting matching tracks for percentage limit", "playlist", pls.Name, "id", pls.ID, err)
return err
}
rulesSQL.ResolveLimit(res.Count)
log.Debug(r.ctx, "Resolved percentage limit", "playlist", pls.Name, "percent", rulesSQL.LimitPercent, "totalMatching", res.Count, "resolvedLimit", rulesSQL.Limit)
return nil
}
func (r *playlistRepository) buildSmartPlaylistQuery(pls *model.Playlist, rulesSQL smartPlaylistCriteria, userID string) SelectBuilder {
orderBy := rulesSQL.OrderBy()
sq := Select("row_number() over (order by "+orderBy+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id").
From("media_file")
sq = r.addMediaFileAnnotationJoin(sq, userID)
requiredJoins := rulesSQL.RequiredJoins()
sq = r.addSmartPlaylistAnnotationJoins(sq, requiredJoins, userID)
sq = r.applyLibraryFilter(sq, "media_file")
return sq
}
func (r *playlistRepository) addMediaFileAnnotationJoin(sq SelectBuilder, userID string) SelectBuilder {
return sq.LeftJoin("annotation on ("+
"annotation.item_id = media_file.id"+
" AND annotation.item_type = 'media_file'"+
" AND annotation.user_id = ?)", userID)
}
func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, joins smartPlaylistJoinType, userID string) SelectBuilder {
if joins.has(smartPlaylistJoinAlbumAnnotation) {
sq = sq.LeftJoin("annotation AS album_annotation ON ("+
"album_annotation.item_id = media_file.album_id"+
" AND album_annotation.item_type = 'album'"+
" AND album_annotation.user_id = ?)", userID)
}
if joins.has(smartPlaylistJoinArtistAnnotation) {
sq = sq.LeftJoin("annotation AS artist_annotation ON ("+
"artist_annotation.item_id = media_file.artist_id"+
" AND artist_annotation.item_type = 'artist'"+
" AND artist_annotation.user_id = ?)", userID)
}
return sq
}
func (r *playlistRepository) addCriteria(sql SelectBuilder, cSQL smartPlaylistCriteria) (SelectBuilder, error) {
cond, err := cSQL.Where()
if err != nil {
return sql, err
}
sql = sql.Where(cond)
if cSQL.Criteria.Limit > 0 {
sql = sql.Limit(uint64(cSQL.Criteria.Limit)).Offset(uint64(cSQL.Criteria.Offset))
}
if order := cSQL.OrderBy(); order != "" {
sql = sql.OrderBy(order)
}
return sql, nil
}