mirror of
https://github.com/navidrome/navidrome.git
synced 2026-07-09 17:18:45 +00:00
198 lines
6.1 KiB
Go
198 lines
6.1 KiB
Go
package plugins
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"fmt"
|
|
"maps"
|
|
"slices"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/core/matcher"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
"github.com/navidrome/navidrome/plugins/host"
|
|
"github.com/navidrome/navidrome/plugins/types"
|
|
"github.com/navidrome/navidrome/utils/slice"
|
|
)
|
|
|
|
type matcherServiceImpl struct {
|
|
ds model.DataStore
|
|
hasFilesystemPerm bool
|
|
users userAccess
|
|
libs libraryAccess
|
|
}
|
|
|
|
func newMatcherService(ds model.DataStore, hasFilesystemPerm bool, users userAccess, libs libraryAccess) host.MatcherService {
|
|
return &matcherServiceImpl{
|
|
ds: ds,
|
|
hasFilesystemPerm: hasFilesystemPerm,
|
|
users: users,
|
|
libs: libs,
|
|
}
|
|
}
|
|
|
|
func (s *matcherServiceImpl) MatchSongs(ctx context.Context, songs []types.SongRef, opts host.MatchOptions) ([]*types.Track, error) {
|
|
results := make([]*types.Track, len(songs))
|
|
if len(songs) == 0 {
|
|
return results, nil
|
|
}
|
|
|
|
// Fail closed when the plugin has no library scope, rather than matching nothing.
|
|
if !s.libs.configured() {
|
|
return nil, fmt.Errorf("matcher: no libraries configured for this plugin")
|
|
}
|
|
|
|
// Set the user context explicitly so the match never inherits the request user
|
|
// of whatever invoked the plugin: a username scopes to that user (loading their
|
|
// annotations and library access), and an unscoped match runs as admin so only
|
|
// the plugin's own library scope constrains the results.
|
|
scoped := opts.Username != ""
|
|
if scoped {
|
|
usr, err := s.users.resolve(ctx, s.ds, opts.Username)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("matcher: %w", err)
|
|
}
|
|
ctx = request.WithUser(ctx, *usr)
|
|
} else {
|
|
ctx = adminContext(ctx)
|
|
}
|
|
|
|
agentSongs := slice.Map(songs, songRefToAgentSong)
|
|
|
|
matched, err := matcher.New(s.ds).MatchSongsIndexed(ctx, agentSongs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// The plugin's library scope is a second, independent authorization on top of the context
|
|
// user's own access, so it's applied here rather than in-query: the unscoped path runs as
|
|
// admin (which applyLibraryFilter skips), and folding s.libs into the context user would
|
|
// conflate the two scopes instead of intersecting them.
|
|
for i, mf := range matched {
|
|
// Drop tracks outside the plugin's library scope, leaving that index unmatched.
|
|
if !s.libs.contains(mf.LibraryID) {
|
|
continue
|
|
}
|
|
results[i] = s.toTrack(&mf, scoped)
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
// toTrack projects a MediaFile into the public Track DTO. Path needs filesystem
|
|
// permission; per-user annotations are only set for a scoped match.
|
|
func (s *matcherServiceImpl) toTrack(mf *model.MediaFile, scoped bool) *types.Track {
|
|
t := &types.Track{
|
|
ID: mf.ID,
|
|
LibraryID: int32(mf.LibraryID),
|
|
LibraryName: mf.LibraryName,
|
|
Missing: mf.Missing,
|
|
Title: mf.Title,
|
|
Album: mf.Album,
|
|
Artist: mf.Artist,
|
|
AlbumArtist: mf.AlbumArtist,
|
|
AlbumID: mf.AlbumID,
|
|
SortTitle: mf.SortTitle,
|
|
SortAlbumName: mf.SortAlbumName,
|
|
SortArtistName: mf.SortArtistName,
|
|
TrackNumber: int32(mf.TrackNumber),
|
|
DiscNumber: int32(mf.DiscNumber),
|
|
DiscSubtitle: mf.DiscSubtitle,
|
|
Year: int32(mf.Year),
|
|
Date: mf.Date,
|
|
OriginalYear: int32(mf.OriginalYear),
|
|
OriginalDate: mf.OriginalDate,
|
|
ReleaseYear: int32(mf.ReleaseYear),
|
|
ReleaseDate: mf.ReleaseDate,
|
|
Size: mf.Size,
|
|
Suffix: mf.Suffix,
|
|
Duration: float64(mf.Duration),
|
|
BitRate: int32(mf.BitRate),
|
|
SampleRate: int32(mf.SampleRate),
|
|
BitDepth: ptrInt32(mf.BitDepth),
|
|
Channels: int32(mf.Channels),
|
|
Codec: mf.Codec,
|
|
Comment: mf.Comment,
|
|
BPM: ptrInt32(mf.BPM),
|
|
ExplicitStatus: mf.ExplicitStatus,
|
|
CatalogNum: mf.CatalogNum,
|
|
Compilation: mf.Compilation,
|
|
HasCoverArt: mf.HasCoverArt,
|
|
MbzRecordingID: mf.MbzRecordingID,
|
|
MbzReleaseTrackID: mf.MbzReleaseTrackID,
|
|
MbzAlbumID: mf.MbzAlbumID,
|
|
MbzReleaseGroupID: mf.MbzReleaseGroupID,
|
|
MbzAlbumType: mf.MbzAlbumType,
|
|
MbzAlbumComment: mf.MbzAlbumComment,
|
|
RGAlbumGain: mf.RGAlbumGain,
|
|
RGAlbumPeak: mf.RGAlbumPeak,
|
|
RGTrackGain: mf.RGTrackGain,
|
|
RGTrackPeak: mf.RGTrackPeak,
|
|
AverageRating: mf.AverageRating, // aggregate, not user-scoped
|
|
BirthTime: unixOrZero(mf.BirthTime),
|
|
CreatedAt: unixOrZero(mf.CreatedAt),
|
|
UpdatedAt: unixOrZero(mf.UpdatedAt),
|
|
}
|
|
if s.hasFilesystemPerm {
|
|
t.Path = mf.Path
|
|
}
|
|
if len(mf.Genres) > 0 {
|
|
t.Genres = slice.Map(mf.Genres, func(g model.Genre) string { return g.Name })
|
|
}
|
|
if len(mf.Tags) > 0 {
|
|
t.Tags = make(map[string][]string, len(mf.Tags))
|
|
for name, values := range mf.Tags {
|
|
t.Tags[string(name)] = values
|
|
}
|
|
}
|
|
if len(mf.Participants) > 0 {
|
|
// Flatten the role→artists map into a role-tagged list, in stable role order.
|
|
roles := slices.SortedFunc(maps.Keys(mf.Participants), func(a, b model.Role) int {
|
|
return cmp.Compare(a.String(), b.String())
|
|
})
|
|
for _, role := range roles {
|
|
for _, p := range mf.Participants[role] {
|
|
t.Participants = append(t.Participants, types.ArtistRef{
|
|
ID: p.ID,
|
|
Name: p.Name,
|
|
MBID: p.MbzArtistID,
|
|
SortName: p.SortArtistName,
|
|
Role: role.String(),
|
|
SubRole: p.SubRole,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
if scoped {
|
|
t.Starred = mf.Starred
|
|
t.StarredAt = unixPtr(mf.StarredAt)
|
|
t.Rating = int32(mf.Rating)
|
|
t.PlayCount = mf.PlayCount
|
|
t.PlayDate = unixPtr(mf.PlayDate)
|
|
}
|
|
return t
|
|
}
|
|
|
|
func unixOrZero(t time.Time) int64 {
|
|
if t.IsZero() {
|
|
return 0
|
|
}
|
|
return t.Unix()
|
|
}
|
|
|
|
// unixPtr maps a nullable time to Unix seconds, keeping nil distinct from the epoch.
|
|
func unixPtr(t *time.Time) *int64 {
|
|
if t == nil || t.IsZero() {
|
|
return nil
|
|
}
|
|
return new(t.Unix())
|
|
}
|
|
|
|
// ptrInt32 narrows a nullable *int to *int32, keeping nil distinct from a real 0.
|
|
func ptrInt32(p *int) *int32 {
|
|
if p == nil {
|
|
return nil
|
|
}
|
|
return new(int32(*p))
|
|
}
|
|
|
|
var _ host.MatcherService = (*matcherServiceImpl)(nil)
|