mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
* feat(ui): add Share button to artist detail page * feat(ui): add Download button to artist detail page * fix(ui): scope artist share/download to album-artist content Gate the artist Share/Download actions on album-artist stats and show the album-artist size, since ZipArtist and the share query only cover album_artist_id songs. Previously the total (role-inclusive) size was shown and guest-only artists could produce an empty archive. Applies to the artist toolbar, the shared context menu, and the download dialog title. * fix: match artist download/share to album-artist participation ZipArtist and the artist share query filtered the deprecated album_artist_id column, which only stores the first album artist of a track. Secondary album-artists (co-credited but not first) got an empty download/share even though the UI offered it. Filter by the album-artist role participation instead, matching the artist's album-artist stats used to gate the actions. Also cover the artist-specific size branch of the download dialog. * fix(share): scope artist shares to the owner's libraries The artist share query broadened to album-artist participation, which could pull a secondary album artist's tracks from libraries the (non-admin) share owner cannot access into the public share. Load the artist share as the owner so their library access is applied, mirroring how playlist shares already work. Adds a repository test covering co-album-artist inclusion and library scoping. * test(share): assert album participation branch of artist shares Link the co-album-artist fixtures to albums and assert share.Albums (used by Subsonic getShares) includes the accessible album and excludes the one in a library the owner cannot access, so the album participation + scoping branch is covered too. * fix: exclude missing files from artist download/share actions An artist's stats still count files that went missing, so the toolbar/context menu could offer Download/Share for an artist whose files are all gone, while the share query (missing=false) returns nothing and downloads open dead paths. Hide the actions when the artist is missing and exclude missing files from ZipArtist, matching the share semantics. * refactor: dedupe artist download-size and share-owner lookups Extract the 'album-artist download size (or none when missing)' rule into a single artistDownloadSize() helper shared by the toolbar, context menu, and download dialog, and factor the duplicated share-owner context lookup into a shareRepository.ownerContext() method used by both the artist and playlist share cases. * refactor(ui): move artistDownloadSize helper to common utils is for domain-agnostic, potentially portable code; this helper is Navidrome-specific (artist stats shape), so it belongs in common. Consumers import it directly from common/artist to avoid pulling in the common barrel.
225 lines
6.3 KiB
Go
225 lines
6.3 KiB
Go
package persistence
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
. "github.com/Masterminds/squirrel"
|
|
"github.com/deluan/rest"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
"github.com/pocketbase/dbx"
|
|
)
|
|
|
|
type shareRepository struct {
|
|
sqlRepository
|
|
}
|
|
|
|
func NewShareRepository(ctx context.Context, db dbx.Builder) model.ShareRepository {
|
|
r := &shareRepository{}
|
|
r.ctx = ctx
|
|
r.db = db
|
|
r.registerModel(&model.Share{}, nil)
|
|
r.setSortMappings(map[string]string{
|
|
"username": "username",
|
|
})
|
|
return r
|
|
}
|
|
|
|
func (r *shareRepository) Delete(id string) error {
|
|
return r.deleteOwned(id)
|
|
}
|
|
|
|
func (r *shareRepository) selectShare(options ...model.QueryOptions) SelectBuilder {
|
|
return r.newSelect(options...).Join("user u on u.id = share.user_id").
|
|
Columns("share.*", "user_name as username").
|
|
Where(r.addRestriction())
|
|
}
|
|
|
|
func (r *shareRepository) Exists(id string) (bool, error) {
|
|
return r.exists(r.addRestriction(And{Eq{"id": id}}))
|
|
}
|
|
|
|
func (r *shareRepository) Get(id string) (*model.Share, error) {
|
|
sel := r.selectShare().Where(Eq{"share.id": id})
|
|
var res model.Share
|
|
err := r.queryOne(sel, &res)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
err = r.loadMedia(&res)
|
|
return &res, err
|
|
}
|
|
|
|
func (r *shareRepository) GetAll(options ...model.QueryOptions) (model.Shares, error) {
|
|
sq := r.selectShare(options...)
|
|
res := model.Shares{}
|
|
err := r.queryAll(sq, &res)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range res {
|
|
err = r.loadMedia(&res[i])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error loading media for share %s: %w", res[i].ID, err)
|
|
}
|
|
}
|
|
return res, err
|
|
}
|
|
|
|
func (r *shareRepository) loadMedia(share *model.Share) error {
|
|
var err error
|
|
ids := strings.Split(share.ResourceIDs, ",")
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
noMissing := func(cond Sqlizer) Sqlizer {
|
|
return And{cond, Eq{"missing": false}}
|
|
}
|
|
switch share.ResourceType {
|
|
case "artist":
|
|
// Match by album-artist participation, not the deprecated album_artist_id
|
|
// column (first album artist only), so co-album-artists are included too.
|
|
// Load as the share owner so their library access is applied.
|
|
ctx, err := r.ownerContext(share)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
albumRepo := NewAlbumRepository(ctx, r.db)
|
|
share.Albums, err = albumRepo.GetAll(model.QueryOptions{Filters: noMissing(ParticipantIDFilter("album", ids, model.RoleAlbumArtist)), Sort: "artist"})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
mfRepo := NewMediaFileRepository(ctx, r.db)
|
|
share.Tracks, err = mfRepo.GetAll(model.QueryOptions{Filters: noMissing(ParticipantIDFilter("media_file", ids, model.RoleAlbumArtist)), Sort: "artist"})
|
|
return err
|
|
case "album":
|
|
albumRepo := NewAlbumRepository(r.ctx, r.db)
|
|
share.Albums, err = albumRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"album.id": ids})})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
mfRepo := NewMediaFileRepository(r.ctx, r.db)
|
|
share.Tracks, err = mfRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"album_id": ids}), Sort: "album"})
|
|
return err
|
|
case "playlist":
|
|
// Load tracks as the share owner so their library access is applied.
|
|
ctx, err := r.ownerContext(share)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
plsRepo := NewPlaylistRepository(ctx, r.db)
|
|
// Tracks returns nil when the playlist is no longer visible to the owner
|
|
// (e.g. it was made private after the share was created); leave the share
|
|
// with no tracks rather than exposing it.
|
|
trackRepo := plsRepo.Tracks(ids[0], true)
|
|
if trackRepo == nil {
|
|
return nil
|
|
}
|
|
tracks, err := trackRepo.GetAll(model.QueryOptions{Sort: "id", Filters: noMissing(Eq{})})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
share.Tracks = tracks.MediaFiles()
|
|
return nil
|
|
case "media_file":
|
|
mfRepo := NewMediaFileRepository(r.ctx, r.db)
|
|
tracks, err := mfRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"media_file.id": ids})})
|
|
share.Tracks = sortByIdPosition(tracks, ids)
|
|
return err
|
|
}
|
|
log.Warn(r.ctx, "Unsupported Share ResourceType", "share", share.ID, "resourceType", share.ResourceType)
|
|
return nil
|
|
}
|
|
|
|
// ownerContext returns a context scoped to the share owner, so repository
|
|
// queries apply the owner's library access when a public share is rendered.
|
|
func (r *shareRepository) ownerContext(share *model.Share) (context.Context, error) {
|
|
owner, err := NewUserRepository(r.ctx, r.db).Get(share.UserID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading share owner %q: %w", share.UserID, err)
|
|
}
|
|
if owner == nil {
|
|
return nil, fmt.Errorf("share owner %q not found", share.UserID)
|
|
}
|
|
return request.WithUser(r.ctx, *owner), nil
|
|
}
|
|
|
|
func sortByIdPosition(mfs model.MediaFiles, ids []string) model.MediaFiles {
|
|
m := map[string]int{}
|
|
for i, mf := range mfs {
|
|
m[mf.ID] = i
|
|
}
|
|
var sorted model.MediaFiles
|
|
for _, id := range ids {
|
|
if idx, ok := m[id]; ok {
|
|
sorted = append(sorted, mfs[idx])
|
|
}
|
|
}
|
|
return sorted
|
|
}
|
|
|
|
func (r *shareRepository) Update(id string, entity any, cols ...string) error {
|
|
s := entity.(*model.Share)
|
|
s.ID = id
|
|
s.UpdatedAt = time.Now()
|
|
if len(cols) > 0 {
|
|
cols = append(cols, "updated_at")
|
|
}
|
|
return r.updateOwned(id, s, cols...)
|
|
}
|
|
|
|
func (r *shareRepository) Save(entity any) (string, error) {
|
|
s := entity.(*model.Share)
|
|
// TODO Validate record
|
|
u := loggedUser(r.ctx)
|
|
if s.UserID == "" {
|
|
s.UserID = u.ID
|
|
}
|
|
s.CreatedAt = time.Now()
|
|
s.UpdatedAt = time.Now()
|
|
id, err := r.put(s.ID, s)
|
|
if errors.Is(err, model.ErrNotFound) {
|
|
return "", rest.ErrNotFound
|
|
}
|
|
return id, err
|
|
}
|
|
|
|
func (r *shareRepository) CountAll(options ...model.QueryOptions) (int64, error) {
|
|
return r.count(r.selectShare(), options...)
|
|
}
|
|
|
|
func (r *shareRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
|
return r.CountAll(r.parseRestOptions(r.ctx, options...))
|
|
}
|
|
|
|
func (r *shareRepository) EntityName() string {
|
|
return "share"
|
|
}
|
|
|
|
func (r *shareRepository) NewInstance() any {
|
|
return &model.Share{}
|
|
}
|
|
|
|
func (r *shareRepository) Read(id string) (any, error) {
|
|
sel := r.selectShare().Where(Eq{"share.id": id})
|
|
var res model.Share
|
|
err := r.queryOne(sel, &res)
|
|
return &res, err
|
|
}
|
|
|
|
func (r *shareRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
|
sq := r.selectShare(r.parseRestOptions(r.ctx, options...))
|
|
res := model.Shares{}
|
|
err := r.queryAll(sq, &res)
|
|
return res, err
|
|
}
|
|
|
|
var _ model.ShareRepository = (*shareRepository)(nil)
|
|
var _ rest.Repository = (*shareRepository)(nil)
|
|
var _ rest.Persistable = (*shareRepository)(nil)
|