774 lines
21 KiB
Go
774 lines
21 KiB
Go
package library
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var ErrNotFound = errors.New("not found")
|
|
|
|
type Artist struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
AlbumCount int `json:"albumCount"`
|
|
CoverArtID string `json:"coverArtId"`
|
|
}
|
|
|
|
type Album struct {
|
|
ID string `json:"id"`
|
|
ArtistID string `json:"artistId"`
|
|
ArtistName string `json:"artistName"`
|
|
Title string `json:"title"`
|
|
Year int `json:"year"`
|
|
TrackCount int `json:"trackCount"`
|
|
CoverArtID string `json:"coverArtId"`
|
|
}
|
|
|
|
type Track struct {
|
|
ID string `json:"id"`
|
|
AlbumID string `json:"albumId"`
|
|
ArtistID string `json:"artistId"`
|
|
Title string `json:"title"`
|
|
ArtistName string `json:"artistName"`
|
|
AlbumTitle string `json:"albumTitle"`
|
|
TrackNumber int `json:"trackNumber"`
|
|
DurationSecs int `json:"durationSeconds"`
|
|
FilePath string `json:"filePath"`
|
|
ContentType string `json:"contentType"`
|
|
CoverArtID string `json:"coverArtId"`
|
|
}
|
|
|
|
type HomePayload struct {
|
|
RecentAlbums []Album `json:"recentAlbums"`
|
|
RecentTracks []Track `json:"recentTracks"`
|
|
Artists []Artist `json:"artists"`
|
|
}
|
|
|
|
type ArtistDetail struct {
|
|
Artist
|
|
Albums []Album `json:"albums"`
|
|
}
|
|
|
|
type AlbumDetail struct {
|
|
Album
|
|
Tracks []Track `json:"tracks"`
|
|
}
|
|
|
|
type SearchResults struct {
|
|
Artists []Artist `json:"artists"`
|
|
Albums []Album `json:"albums"`
|
|
Tracks []Track `json:"tracks"`
|
|
}
|
|
|
|
type StarredResults struct {
|
|
Artists []Artist `json:"artists"`
|
|
Albums []Album `json:"albums"`
|
|
Tracks []Track `json:"tracks"`
|
|
}
|
|
|
|
type Service struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
func NewService(db *sql.DB) *Service {
|
|
return &Service{db: db}
|
|
}
|
|
|
|
func (s *Service) Home(ctx context.Context) (HomePayload, error) {
|
|
albums, err := s.RecentAlbums(ctx, 6)
|
|
if err != nil {
|
|
return HomePayload{}, err
|
|
}
|
|
|
|
artists, err := s.Artists(ctx, 12)
|
|
if err != nil {
|
|
return HomePayload{}, err
|
|
}
|
|
|
|
recentTracks, err := s.RecentTracks(ctx, "", 8)
|
|
if err != nil {
|
|
return HomePayload{}, err
|
|
}
|
|
|
|
return HomePayload{
|
|
RecentAlbums: albums,
|
|
RecentTracks: recentTracks,
|
|
Artists: artists,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) Artists(ctx context.Context, limit int) ([]Artist, error) {
|
|
rows, err := s.db.QueryContext(
|
|
ctx,
|
|
`SELECT a.id, a.name, COUNT(al.id) AS album_count
|
|
, COALESCE(a.cover_art_id, '')
|
|
FROM artists a
|
|
LEFT JOIN albums al ON al.artist_id = a.id
|
|
GROUP BY a.id, a.name
|
|
ORDER BY a.name ASC
|
|
LIMIT ?`,
|
|
limit,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query artists: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var artists []Artist
|
|
for rows.Next() {
|
|
var artist Artist
|
|
if err := rows.Scan(&artist.ID, &artist.Name, &artist.AlbumCount, &artist.CoverArtID); err != nil {
|
|
return nil, fmt.Errorf("scan artist: %w", err)
|
|
}
|
|
artists = append(artists, artist)
|
|
}
|
|
|
|
return artists, rows.Err()
|
|
}
|
|
|
|
func (s *Service) ArtistByID(ctx context.Context, id string) (ArtistDetail, error) {
|
|
var artist ArtistDetail
|
|
|
|
err := s.db.QueryRowContext(
|
|
ctx,
|
|
`SELECT a.id, a.name, COUNT(al.id) AS album_count, COALESCE(a.cover_art_id, '')
|
|
FROM artists a
|
|
LEFT JOIN albums al ON al.artist_id = a.id
|
|
WHERE a.id = ?
|
|
GROUP BY a.id, a.name, a.cover_art_id`,
|
|
id,
|
|
).Scan(&artist.ID, &artist.Name, &artist.AlbumCount, &artist.CoverArtID)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return ArtistDetail{}, ErrNotFound
|
|
}
|
|
return ArtistDetail{}, fmt.Errorf("query artist by id: %w", err)
|
|
}
|
|
|
|
albums, err := s.albumsByArtistID(ctx, id)
|
|
if err != nil {
|
|
return ArtistDetail{}, err
|
|
}
|
|
artist.Albums = albums
|
|
|
|
return artist, nil
|
|
}
|
|
|
|
func (s *Service) RecentAlbums(ctx context.Context, limit int) ([]Album, error) {
|
|
rows, err := s.db.QueryContext(
|
|
ctx,
|
|
`SELECT al.id, al.artist_id, a.name, al.title, COALESCE(al.year, 0), COUNT(t.id) AS track_count
|
|
, COALESCE(al.cover_art_id, '')
|
|
FROM albums al
|
|
JOIN artists a ON a.id = al.artist_id
|
|
LEFT JOIN tracks t ON t.album_id = al.id
|
|
GROUP BY al.id, al.artist_id, a.name, al.title, al.year
|
|
ORDER BY al.updated_at DESC, al.title ASC
|
|
LIMIT ?`,
|
|
limit,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query albums: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var albums []Album
|
|
for rows.Next() {
|
|
var album Album
|
|
if err := rows.Scan(&album.ID, &album.ArtistID, &album.ArtistName, &album.Title, &album.Year, &album.TrackCount, &album.CoverArtID); err != nil {
|
|
return nil, fmt.Errorf("scan album: %w", err)
|
|
}
|
|
albums = append(albums, album)
|
|
}
|
|
|
|
return albums, rows.Err()
|
|
}
|
|
|
|
func (s *Service) Albums(ctx context.Context, limit int) ([]Album, error) {
|
|
rows, err := s.db.QueryContext(
|
|
ctx,
|
|
`SELECT al.id, al.artist_id, a.name, al.title, COALESCE(al.year, 0), COUNT(t.id) AS track_count, COALESCE(al.cover_art_id, '')
|
|
FROM albums al
|
|
JOIN artists a ON a.id = al.artist_id
|
|
LEFT JOIN tracks t ON t.album_id = al.id
|
|
GROUP BY al.id, al.artist_id, a.name, al.title, al.year, al.cover_art_id
|
|
ORDER BY al.year DESC, al.title ASC
|
|
LIMIT ?`,
|
|
limit,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query all albums: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var albums []Album
|
|
for rows.Next() {
|
|
var album Album
|
|
if err := rows.Scan(&album.ID, &album.ArtistID, &album.ArtistName, &album.Title, &album.Year, &album.TrackCount, &album.CoverArtID); err != nil {
|
|
return nil, fmt.Errorf("scan all albums: %w", err)
|
|
}
|
|
albums = append(albums, album)
|
|
}
|
|
return albums, rows.Err()
|
|
}
|
|
|
|
func (s *Service) AlbumByID(ctx context.Context, id string) (AlbumDetail, error) {
|
|
var album AlbumDetail
|
|
|
|
err := s.db.QueryRowContext(
|
|
ctx,
|
|
`SELECT al.id, al.artist_id, a.name, al.title, COALESCE(al.year, 0),
|
|
COUNT(t.id) AS track_count, COALESCE(al.cover_art_id, '')
|
|
FROM albums al
|
|
JOIN artists a ON a.id = al.artist_id
|
|
LEFT JOIN tracks t ON t.album_id = al.id
|
|
WHERE al.id = ?
|
|
GROUP BY al.id, al.artist_id, a.name, al.title, al.year, al.cover_art_id`,
|
|
id,
|
|
).Scan(
|
|
&album.ID,
|
|
&album.ArtistID,
|
|
&album.ArtistName,
|
|
&album.Title,
|
|
&album.Year,
|
|
&album.TrackCount,
|
|
&album.CoverArtID,
|
|
)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return AlbumDetail{}, ErrNotFound
|
|
}
|
|
return AlbumDetail{}, fmt.Errorf("query album by id: %w", err)
|
|
}
|
|
|
|
tracks, err := s.tracksByAlbumID(ctx, id)
|
|
if err != nil {
|
|
return AlbumDetail{}, err
|
|
}
|
|
album.Tracks = tracks
|
|
|
|
return album, nil
|
|
}
|
|
|
|
func (s *Service) Tracks(ctx context.Context, limit int) ([]Track, error) {
|
|
rows, err := s.db.QueryContext(
|
|
ctx,
|
|
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(t.track_number, 0),
|
|
COALESCE(t.duration_seconds, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '')
|
|
FROM tracks t
|
|
JOIN artists a ON a.id = t.artist_id
|
|
JOIN albums al ON al.id = t.album_id
|
|
ORDER BY a.name ASC, al.year DESC, al.title ASC, t.disc_number ASC, t.track_number ASC
|
|
LIMIT ?`,
|
|
limit,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query tracks: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var tracks []Track
|
|
for rows.Next() {
|
|
var track Track
|
|
if err := rows.Scan(
|
|
&track.ID,
|
|
&track.AlbumID,
|
|
&track.ArtistID,
|
|
&track.Title,
|
|
&track.ArtistName,
|
|
&track.AlbumTitle,
|
|
&track.TrackNumber,
|
|
&track.DurationSecs,
|
|
&track.FilePath,
|
|
&track.ContentType,
|
|
&track.CoverArtID,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("scan track: %w", err)
|
|
}
|
|
tracks = append(tracks, track)
|
|
}
|
|
|
|
return tracks, rows.Err()
|
|
}
|
|
|
|
func (s *Service) TrackByID(ctx context.Context, id string) (Track, error) {
|
|
var track Track
|
|
|
|
err := s.db.QueryRowContext(
|
|
ctx,
|
|
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(t.track_number, 0),
|
|
COALESCE(t.duration_seconds, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '')
|
|
FROM tracks t
|
|
JOIN artists a ON a.id = t.artist_id
|
|
JOIN albums al ON al.id = t.album_id
|
|
WHERE t.id = ?`,
|
|
id,
|
|
).Scan(
|
|
&track.ID,
|
|
&track.AlbumID,
|
|
&track.ArtistID,
|
|
&track.Title,
|
|
&track.ArtistName,
|
|
&track.AlbumTitle,
|
|
&track.TrackNumber,
|
|
&track.DurationSecs,
|
|
&track.FilePath,
|
|
&track.ContentType,
|
|
&track.CoverArtID,
|
|
)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Track{}, ErrNotFound
|
|
}
|
|
return Track{}, fmt.Errorf("query track by id: %w", err)
|
|
}
|
|
|
|
return track, nil
|
|
}
|
|
|
|
func (s *Service) Search(ctx context.Context, query string, limit int) (SearchResults, error) {
|
|
pattern := "%" + query + "%"
|
|
|
|
artists, err := s.searchArtists(ctx, pattern, limit)
|
|
if err != nil {
|
|
return SearchResults{}, err
|
|
}
|
|
|
|
albums, err := s.searchAlbums(ctx, pattern, limit)
|
|
if err != nil {
|
|
return SearchResults{}, err
|
|
}
|
|
|
|
tracks, err := s.searchTracks(ctx, pattern, limit)
|
|
if err != nil {
|
|
return SearchResults{}, err
|
|
}
|
|
|
|
return SearchResults{
|
|
Artists: artists,
|
|
Albums: albums,
|
|
Tracks: tracks,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) Starred(ctx context.Context, userID string) (StarredResults, error) {
|
|
artists, err := s.starredArtists(ctx, userID)
|
|
if err != nil {
|
|
return StarredResults{}, err
|
|
}
|
|
albums, err := s.starredAlbums(ctx, userID)
|
|
if err != nil {
|
|
return StarredResults{}, err
|
|
}
|
|
tracks, err := s.starredTracks(ctx, userID)
|
|
if err != nil {
|
|
return StarredResults{}, err
|
|
}
|
|
return StarredResults{
|
|
Artists: artists,
|
|
Albums: albums,
|
|
Tracks: tracks,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) Star(ctx context.Context, userID string, trackIDs, albumIDs, artistIDs []string) error {
|
|
return s.updateFavorites(ctx, userID, trackIDs, albumIDs, artistIDs, true)
|
|
}
|
|
|
|
func (s *Service) Unstar(ctx context.Context, userID string, trackIDs, albumIDs, artistIDs []string) error {
|
|
return s.updateFavorites(ctx, userID, trackIDs, albumIDs, artistIDs, false)
|
|
}
|
|
|
|
func (s *Service) CoverArtPathByEntityID(ctx context.Context, id string) (string, error) {
|
|
var path string
|
|
|
|
err := s.db.QueryRowContext(
|
|
ctx,
|
|
`SELECT cover_art_id FROM albums WHERE id = ? AND cover_art_id <> ''`,
|
|
id,
|
|
).Scan(&path)
|
|
if err == nil {
|
|
return path, nil
|
|
}
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return "", fmt.Errorf("query album cover art: %w", err)
|
|
}
|
|
|
|
err = s.db.QueryRowContext(
|
|
ctx,
|
|
`SELECT al.cover_art_id
|
|
FROM tracks t
|
|
JOIN albums al ON al.id = t.album_id
|
|
WHERE t.id = ? AND al.cover_art_id <> ''`,
|
|
id,
|
|
).Scan(&path)
|
|
if err == nil {
|
|
return path, nil
|
|
}
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return "", ErrNotFound
|
|
}
|
|
return "", fmt.Errorf("query track cover art: %w", err)
|
|
}
|
|
|
|
func (s *Service) RecentTracks(ctx context.Context, userID string, limit int) ([]Track, error) {
|
|
if limit <= 0 {
|
|
limit = 10
|
|
}
|
|
|
|
if userID != "" {
|
|
rows, err := s.db.QueryContext(
|
|
ctx,
|
|
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(t.track_number, 0),
|
|
COALESCE(t.duration_seconds, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '')
|
|
FROM tracks t
|
|
JOIN artists a ON a.id = t.artist_id
|
|
JOIN albums al ON al.id = t.album_id
|
|
JOIN (
|
|
SELECT track_id, MAX(played_at) AS last_played_at
|
|
FROM play_history
|
|
WHERE user_id = ?
|
|
GROUP BY track_id
|
|
) history ON history.track_id = t.id
|
|
ORDER BY history.last_played_at DESC
|
|
LIMIT ?`,
|
|
userID,
|
|
limit,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query recent tracks by user: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
return scanTracks(rows)
|
|
}
|
|
|
|
rows, err := s.db.QueryContext(
|
|
ctx,
|
|
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(t.track_number, 0),
|
|
COALESCE(t.duration_seconds, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '')
|
|
FROM tracks t
|
|
JOIN artists a ON a.id = t.artist_id
|
|
JOIN albums al ON al.id = t.album_id
|
|
ORDER BY al.updated_at DESC, t.updated_at DESC, t.track_number ASC
|
|
LIMIT ?`,
|
|
limit,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query fallback recent tracks: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
return scanTracks(rows)
|
|
}
|
|
|
|
func (s *Service) RecordPlayEvent(ctx context.Context, userID, trackID, eventType, clientName string, playedAt time.Time, submission bool) error {
|
|
if strings.TrimSpace(userID) == "" || strings.TrimSpace(trackID) == "" {
|
|
return nil
|
|
}
|
|
if eventType == "" {
|
|
eventType = "play"
|
|
}
|
|
if playedAt.IsZero() {
|
|
playedAt = time.Now().UTC()
|
|
}
|
|
_, err := s.db.ExecContext(
|
|
ctx,
|
|
`INSERT INTO play_history (id, user_id, track_id, event_type, played_at, client_name, submission)
|
|
VALUES (lower(hex(randomblob(16))), ?, ?, ?, ?, ?, ?)`,
|
|
userID,
|
|
trackID,
|
|
eventType,
|
|
playedAt.UTC().Format(time.RFC3339),
|
|
strings.TrimSpace(clientName),
|
|
boolToInt(submission),
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("insert play history event: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) albumsByArtistID(ctx context.Context, artistID string) ([]Album, error) {
|
|
rows, err := s.db.QueryContext(
|
|
ctx,
|
|
`SELECT al.id, al.artist_id, a.name, al.title, COALESCE(al.year, 0), COUNT(t.id) AS track_count, COALESCE(al.cover_art_id, '')
|
|
FROM albums al
|
|
JOIN artists a ON a.id = al.artist_id
|
|
LEFT JOIN tracks t ON t.album_id = al.id
|
|
WHERE al.artist_id = ?
|
|
GROUP BY al.id, al.artist_id, a.name, al.title, al.year, al.cover_art_id
|
|
ORDER BY al.year DESC, al.title ASC`,
|
|
artistID,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query albums by artist: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var albums []Album
|
|
for rows.Next() {
|
|
var album Album
|
|
if err := rows.Scan(&album.ID, &album.ArtistID, &album.ArtistName, &album.Title, &album.Year, &album.TrackCount, &album.CoverArtID); err != nil {
|
|
return nil, fmt.Errorf("scan album by artist: %w", err)
|
|
}
|
|
albums = append(albums, album)
|
|
}
|
|
|
|
return albums, rows.Err()
|
|
}
|
|
|
|
func (s *Service) tracksByAlbumID(ctx context.Context, albumID string) ([]Track, error) {
|
|
rows, err := s.db.QueryContext(
|
|
ctx,
|
|
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(t.track_number, 0),
|
|
COALESCE(t.duration_seconds, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '')
|
|
FROM tracks t
|
|
JOIN artists a ON a.id = t.artist_id
|
|
JOIN albums al ON al.id = t.album_id
|
|
WHERE t.album_id = ?
|
|
ORDER BY t.disc_number ASC, t.track_number ASC, t.title ASC`,
|
|
albumID,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query tracks by album: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
return scanTracks(rows)
|
|
}
|
|
|
|
func (s *Service) searchArtists(ctx context.Context, pattern string, limit int) ([]Artist, error) {
|
|
rows, err := s.db.QueryContext(
|
|
ctx,
|
|
`SELECT a.id, a.name, COUNT(al.id) AS album_count, COALESCE(a.cover_art_id, '')
|
|
FROM artists a
|
|
LEFT JOIN albums al ON al.artist_id = a.id
|
|
WHERE a.name LIKE ?
|
|
GROUP BY a.id, a.name, a.cover_art_id
|
|
ORDER BY a.name ASC
|
|
LIMIT ?`,
|
|
pattern,
|
|
limit,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("search artists: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var artists []Artist
|
|
for rows.Next() {
|
|
var artist Artist
|
|
if err := rows.Scan(&artist.ID, &artist.Name, &artist.AlbumCount, &artist.CoverArtID); err != nil {
|
|
return nil, fmt.Errorf("scan searched artist: %w", err)
|
|
}
|
|
artists = append(artists, artist)
|
|
}
|
|
return artists, rows.Err()
|
|
}
|
|
|
|
func (s *Service) searchAlbums(ctx context.Context, pattern string, limit int) ([]Album, error) {
|
|
rows, err := s.db.QueryContext(
|
|
ctx,
|
|
`SELECT al.id, al.artist_id, a.name, al.title, COALESCE(al.year, 0), COUNT(t.id), COALESCE(al.cover_art_id, '')
|
|
FROM albums al
|
|
JOIN artists a ON a.id = al.artist_id
|
|
LEFT JOIN tracks t ON t.album_id = al.id
|
|
WHERE al.title LIKE ? OR a.name LIKE ?
|
|
GROUP BY al.id, al.artist_id, a.name, al.title, al.year, al.cover_art_id
|
|
ORDER BY al.year DESC, al.title ASC
|
|
LIMIT ?`,
|
|
pattern,
|
|
pattern,
|
|
limit,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("search albums: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var albums []Album
|
|
for rows.Next() {
|
|
var album Album
|
|
if err := rows.Scan(&album.ID, &album.ArtistID, &album.ArtistName, &album.Title, &album.Year, &album.TrackCount, &album.CoverArtID); err != nil {
|
|
return nil, fmt.Errorf("scan searched album: %w", err)
|
|
}
|
|
albums = append(albums, album)
|
|
}
|
|
return albums, rows.Err()
|
|
}
|
|
|
|
func (s *Service) searchTracks(ctx context.Context, pattern string, limit int) ([]Track, error) {
|
|
rows, err := s.db.QueryContext(
|
|
ctx,
|
|
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(t.track_number, 0),
|
|
COALESCE(t.duration_seconds, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '')
|
|
FROM tracks t
|
|
JOIN artists a ON a.id = t.artist_id
|
|
JOIN albums al ON al.id = t.album_id
|
|
WHERE t.title LIKE ? OR a.name LIKE ? OR al.title LIKE ?
|
|
ORDER BY a.name ASC, al.title ASC, t.track_number ASC
|
|
LIMIT ?`,
|
|
pattern,
|
|
pattern,
|
|
pattern,
|
|
limit,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("search tracks: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
return scanTracks(rows)
|
|
}
|
|
|
|
func (s *Service) updateFavorites(ctx context.Context, userID string, trackIDs, albumIDs, artistIDs []string, star bool) error {
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("begin favorites transaction: %w", err)
|
|
}
|
|
|
|
ops := []struct {
|
|
ids []string
|
|
entityType string
|
|
}{
|
|
{ids: trackIDs, entityType: "track"},
|
|
{ids: albumIDs, entityType: "album"},
|
|
{ids: artistIDs, entityType: "artist"},
|
|
}
|
|
|
|
for _, op := range ops {
|
|
for _, id := range op.ids {
|
|
if id == "" {
|
|
continue
|
|
}
|
|
if star {
|
|
if _, err := tx.ExecContext(ctx, `INSERT OR REPLACE INTO favorites (user_id, entity_id, entity_type, created_at) VALUES (?, ?, ?, datetime('now'))`, userID, id, op.entityType); err != nil {
|
|
_ = tx.Rollback()
|
|
return fmt.Errorf("insert favorite: %w", err)
|
|
}
|
|
continue
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM favorites WHERE user_id = ? AND entity_id = ? AND entity_type = ?`, userID, id, op.entityType); err != nil {
|
|
_ = tx.Rollback()
|
|
return fmt.Errorf("delete favorite: %w", err)
|
|
}
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("commit favorites transaction: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) starredArtists(ctx context.Context, userID string) ([]Artist, error) {
|
|
rows, err := s.db.QueryContext(
|
|
ctx,
|
|
`SELECT a.id, a.name, COUNT(al.id) AS album_count, COALESCE(a.cover_art_id, '')
|
|
FROM favorites f
|
|
JOIN artists a ON a.id = f.entity_id
|
|
LEFT JOIN albums al ON al.artist_id = a.id
|
|
WHERE f.user_id = ? AND f.entity_type = 'artist'
|
|
GROUP BY a.id, a.name, a.cover_art_id
|
|
ORDER BY f.created_at DESC, a.name ASC`,
|
|
userID,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query starred artists: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var artists []Artist
|
|
for rows.Next() {
|
|
var artist Artist
|
|
if err := rows.Scan(&artist.ID, &artist.Name, &artist.AlbumCount, &artist.CoverArtID); err != nil {
|
|
return nil, fmt.Errorf("scan starred artist: %w", err)
|
|
}
|
|
artists = append(artists, artist)
|
|
}
|
|
return artists, rows.Err()
|
|
}
|
|
|
|
func (s *Service) starredAlbums(ctx context.Context, userID string) ([]Album, error) {
|
|
rows, err := s.db.QueryContext(
|
|
ctx,
|
|
`SELECT al.id, al.artist_id, a.name, al.title, COALESCE(al.year, 0), COUNT(t.id), COALESCE(al.cover_art_id, '')
|
|
FROM favorites f
|
|
JOIN albums al ON al.id = f.entity_id
|
|
JOIN artists a ON a.id = al.artist_id
|
|
LEFT JOIN tracks t ON t.album_id = al.id
|
|
WHERE f.user_id = ? AND f.entity_type = 'album'
|
|
GROUP BY al.id, al.artist_id, a.name, al.title, al.year, al.cover_art_id
|
|
ORDER BY f.created_at DESC, al.title ASC`,
|
|
userID,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query starred albums: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var albums []Album
|
|
for rows.Next() {
|
|
var album Album
|
|
if err := rows.Scan(&album.ID, &album.ArtistID, &album.ArtistName, &album.Title, &album.Year, &album.TrackCount, &album.CoverArtID); err != nil {
|
|
return nil, fmt.Errorf("scan starred album: %w", err)
|
|
}
|
|
albums = append(albums, album)
|
|
}
|
|
return albums, rows.Err()
|
|
}
|
|
|
|
func (s *Service) starredTracks(ctx context.Context, userID string) ([]Track, error) {
|
|
rows, err := s.db.QueryContext(
|
|
ctx,
|
|
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(t.track_number, 0),
|
|
COALESCE(t.duration_seconds, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '')
|
|
FROM favorites f
|
|
JOIN tracks t ON t.id = f.entity_id
|
|
JOIN artists a ON a.id = t.artist_id
|
|
JOIN albums al ON al.id = t.album_id
|
|
WHERE f.user_id = ? AND f.entity_type = 'track'
|
|
ORDER BY f.created_at DESC, a.name ASC, al.title ASC, t.track_number ASC`,
|
|
userID,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query starred tracks: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
return scanTracks(rows)
|
|
}
|
|
|
|
func scanTracks(rows *sql.Rows) ([]Track, error) {
|
|
var tracks []Track
|
|
for rows.Next() {
|
|
var track Track
|
|
if err := rows.Scan(
|
|
&track.ID,
|
|
&track.AlbumID,
|
|
&track.ArtistID,
|
|
&track.Title,
|
|
&track.ArtistName,
|
|
&track.AlbumTitle,
|
|
&track.TrackNumber,
|
|
&track.DurationSecs,
|
|
&track.FilePath,
|
|
&track.ContentType,
|
|
&track.CoverArtID,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("scan track row: %w", err)
|
|
}
|
|
tracks = append(tracks, track)
|
|
}
|
|
return tracks, rows.Err()
|
|
}
|
|
|
|
func boolToInt(value bool) int {
|
|
if value {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|