navidrome/plugins/host_matcher.go
Deluan 2d53360f89 docs(plugins): document plugin library scope authorization logic
Signed-off-by: Deluan <deluan@navidrome.org>
2026-07-01 13:20:31 -04:00

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)