🏡 index : quine-global/quintodrome.git

package core

import (
	"context"
	"fmt"
	"slices"
	"strings"
	"time"

	"github.com/Masterminds/squirrel"
	"github.com/navidrome/navidrome/conf"
	"github.com/navidrome/navidrome/log"
	"github.com/navidrome/navidrome/model"
	"github.com/navidrome/navidrome/model/request"
	. "github.com/navidrome/navidrome/utils/gg"
	"github.com/navidrome/navidrome/utils/nanoid"
	"github.com/navidrome/navidrome/utils/slice"
	"github.com/navidrome/navidrome/utils/str"
)

type Share interface {
	Load(ctx context.Context, id string) (*model.Share, error)
	Repository() model.ShareRepository
}

func NewShare(ds model.DataStore) Share {
	return &shareService{
		ds:   ds,
		repo: &shareRepositoryWrapper{ShareRepository: ds.Share(), ds: ds},
	}
}

type shareService struct {
	ds   model.DataStore
	repo *shareRepositoryWrapper
}

func (s *shareService) Load(ctx context.Context, id string) (*model.Share, error) {
	repo := s.ds.Share()
	share, err := repo.Get(ctx, id)
	if err != nil {
		return nil, err
	}
	expiresAt := V(share.ExpiresAt)
	if !expiresAt.IsZero() && expiresAt.Before(time.Now()) {
		return nil, model.ErrExpired
	}
	share.LastVisitedAt = new(time.Now())
	share.VisitCount++

	err = repo.Update(ctx, id, *share, "last_visited_at", "visit_count")
	if err != nil {
		log.Warn(ctx, "Could not increment visit count for share", "share", share.ID)
	}
	return share, nil
}

func (s *shareService) Repository() model.ShareRepository {
	return s.repo
}

type shareRepositoryWrapper struct {
	model.ShareRepository
	ds model.DataStore
}

func (r *shareRepositoryWrapper) newId(ctx context.Context) (string, error) {
	for {
		id, err := nanoid.Generate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 10)
		if err != nil {
			return "", err
		}
		exists, err := r.Exists(ctx, id)
		if err != nil {
			return "", err
		}
		if !exists {
			return id, nil
		}
	}
}

func (r *shareRepositoryWrapper) Save(ctx context.Context, s *model.Share) (string, error) {
	// Owner is always the caller; never trust a client-supplied UserID, as it
	// determines the library-access context used to resolve the share contents.
	if user, ok := request.UserFrom(ctx); ok {
		s.UserID = user.ID
	}
	id, err := r.newId(ctx)
	if err != nil {
		return "", err
	}
	s.ID = id
	if V(s.ExpiresAt).IsZero() {
		s.ExpiresAt = new(time.Now().Add(conf.Server.DefaultShareExpiration))
	}

	s.ResourceType, err = r.resourceType(ctx, s.ResourceIDs)
	if err != nil {
		return "", err
	}
	switch s.ResourceType {
	case "artist":
		s.Contents = r.contentsLabelFromArtist(ctx, s.ID, s.ResourceIDs)
	case "album":
		s.Contents = r.contentsLabelFromAlbums(ctx, s.ID, s.ResourceIDs)
	case "playlist":
		s.Contents = r.contentsLabelFromPlaylist(ctx, s.ID, s.ResourceIDs)
	case "media_file":
		s.Contents = r.contentsLabelFromMediaFiles(ctx, s.ID, s.ResourceIDs)
	}

	s.Contents = str.TruncateRunes(s.Contents, 30, "...")

	return r.ShareRepository.Save(ctx, s)
}

var shareableKinds = []model.Kind{model.KindArtistArtwork, model.KindAlbumArtwork, model.KindPlaylistArtwork, model.KindMediaFileArtwork}

// resourceType resolves every ID as the current user, so an entity they cannot see cannot
// ride along behind a valid first one, and requires all IDs to be of the same kind.
func (r *shareRepositoryWrapper) resourceType(ctx context.Context, resourceIDs string) (string, error) {
	resourceType := ""
	for _, id := range strings.Split(resourceIDs, ",") {
		kind, err := model.GetEntityKindByID(ctx, r.ds, id)
		if err != nil {
			return "", err
		}
		if !slices.Contains(shareableKinds, kind) {
			log.Error(ctx, "Invalid Resource ID", "id", id)
			return "", model.ErrNotFound
		}
		if resourceType != "" && kind.String() != resourceType {
			return "", fmt.Errorf("%w: share mixes %s and %s resources", model.ErrValidation, resourceType, kind)
		}
		resourceType = kind.String()
	}
	return resourceType, nil
}

func (r *shareRepositoryWrapper) Update(ctx context.Context, id string, entity model.Share, _ ...string) error {
	cols := []string{"description", "downloadable"}

	// TODO Better handling of Share expiration
	if !V(entity.ExpiresAt).IsZero() {
		cols = append(cols, "expires_at")
	}
	return r.ShareRepository.Update(ctx, id, entity, cols...)
}

func (r *shareRepositoryWrapper) contentsLabelFromArtist(ctx context.Context, shareID string, ids string) string {
	idList := strings.SplitN(ids, ",", 2)
	a, err := r.ds.Artist().Get(ctx, idList[0])
	if err != nil {
		log.Error(ctx, "Error retrieving artist name for share", "share", shareID, err)
		return ""
	}
	return a.Name
}

func (r *shareRepositoryWrapper) contentsLabelFromAlbums(ctx context.Context, shareID string, ids string) string {
	idList := strings.Split(ids, ",")
	all, err := r.ds.Album().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"album.id": idList}})
	if err != nil {
		log.Error(ctx, "Error retrieving album names for share", "share", shareID, err)
		return ""
	}
	names := slice.Map(all, func(a model.Album) string { return a.Name })
	return strings.Join(names, ", ")
}
func (r *shareRepositoryWrapper) contentsLabelFromPlaylist(ctx context.Context, shareID string, id string) string {
	pls, err := r.ds.Playlist().Get(ctx, id)
	if err != nil {
		log.Error(ctx, "Error retrieving album names for share", "share", shareID, err)
		return ""
	}
	return pls.Name
}

func (r *shareRepositoryWrapper) contentsLabelFromMediaFiles(ctx context.Context, shareID string, ids string) string {
	idList := strings.Split(ids, ",")
	mfs, err := r.ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: squirrel.And{
		squirrel.Eq{"media_file.id": idList},
		squirrel.Eq{"missing": false},
	}})
	if err != nil {
		log.Error(ctx, "Error retrieving media files for share", "share", shareID, err)
		return ""
	}

	if len(mfs) == 1 {
		return mfs[0].Title
	}

	albums := slice.Group(mfs, func(mf model.MediaFile) string {
		return mf.Album
	})
	if len(albums) == 1 {
		for name := range albums {
			return name
		}
	}
	artists := slice.Group(mfs, func(mf model.MediaFile) string {
		return mf.AlbumArtist
	})
	if len(artists) == 1 {
		for name := range artists {
			return name
		}
	}

	return mfs[0].Title
}