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"` Genre string `json:"genre"` 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"` Genre string `json:"genre"` TrackNumber int `json:"trackNumber"` DurationSecs int `json:"durationSeconds"` BitrateKbps int `json:"bitrateKbps"` FilePath string `json:"filePath"` ContentType string `json:"contentType"` CoverArtID string `json:"coverArtId"` PlayCount int `json:"playCount"` LastPlayedAt string `json:"lastPlayedAt"` } 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 GenreSummary struct { Value string `json:"value"` AlbumCount int `json:"albumCount"` SongCount int `json:"songCount"` } 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.genre, '') , 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.Genre, &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.genre, ''), 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.Genre, &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.genre, ''), 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.Genre, &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(al.genre, ''), COALESCE(t.track_number, 0), COALESCE(t.duration_seconds, 0), COALESCE(t.bitrate_kbps, 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.Genre, &track.TrackNumber, &track.DurationSecs, &track.BitrateKbps, &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(al.genre, ''), COALESCE(t.track_number, 0), COALESCE(t.duration_seconds, 0), COALESCE(t.bitrate_kbps, 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.Genre, &track.TrackNumber, &track.DurationSecs, &track.BitrateKbps, &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) Genres(ctx context.Context) ([]GenreSummary, error) { rows, err := s.db.QueryContext( ctx, `SELECT al.genre, COUNT(DISTINCT al.id) AS album_count, COUNT(t.id) AS song_count FROM albums al LEFT JOIN tracks t ON t.album_id = al.id WHERE TRIM(COALESCE(al.genre, '')) <> '' GROUP BY al.genre ORDER BY song_count DESC, album_count DESC, al.genre ASC`, ) if err != nil { return nil, fmt.Errorf("query genres: %w", err) } defer rows.Close() var genres []GenreSummary for rows.Next() { var genre GenreSummary if err := rows.Scan(&genre.Value, &genre.AlbumCount, &genre.SongCount); err != nil { return nil, fmt.Errorf("scan genre: %w", err) } genres = append(genres, genre) } return genres, rows.Err() } func (s *Service) SongsByGenre(ctx context.Context, genre string, count, offset int) ([]Track, error) { if count <= 0 { count = 50 } if offset < 0 { offset = 0 } rows, err := s.db.QueryContext( ctx, `SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(al.genre, ''), COALESCE(t.track_number, 0), COALESCE(t.duration_seconds, 0), COALESCE(t.bitrate_kbps, 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 LOWER(TRIM(COALESCE(al.genre, ''))) = LOWER(TRIM(?)) ORDER BY a.name ASC, al.year DESC, al.title ASC, t.disc_number ASC, t.track_number ASC LIMIT ? OFFSET ?`, genre, count, offset, ) if err != nil { return nil, fmt.Errorf("query songs by genre: %w", err) } defer rows.Close() return scanTracks(rows) } 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(al.genre, ''), COALESCE(t.track_number, 0), COALESCE(t.duration_seconds, 0), COALESCE(t.bitrate_kbps, 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(al.genre, ''), COALESCE(t.track_number, 0), COALESCE(t.duration_seconds, 0), COALESCE(t.bitrate_kbps, 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) PopulateTrackStats(ctx context.Context, userID string, tracks []Track) ([]Track, error) { if userID == "" || len(tracks) == 0 { return tracks, nil } placeholders := make([]string, 0, len(tracks)) args := make([]any, 0, len(tracks)+1) args = append(args, userID) for _, track := range tracks { placeholders = append(placeholders, "?") args = append(args, track.ID) } query := fmt.Sprintf( `SELECT track_id, COUNT(*) AS play_count, COALESCE(MAX(played_at), '') FROM play_history WHERE user_id = ? AND track_id IN (%s) GROUP BY track_id`, strings.Join(placeholders, ","), ) rows, err := s.db.QueryContext(ctx, query, args...) if err != nil { return nil, fmt.Errorf("query track stats: %w", err) } defer rows.Close() type stats struct { playCount int lastPlayedAt string } byTrackID := map[string]stats{} for rows.Next() { var trackID string var item stats if err := rows.Scan(&trackID, &item.playCount, &item.lastPlayedAt); err != nil { return nil, fmt.Errorf("scan track stats: %w", err) } byTrackID[trackID] = item } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate track stats: %w", err) } for index := range tracks { if item, ok := byTrackID[tracks[index].ID]; ok { tracks[index].PlayCount = item.playCount tracks[index].LastPlayedAt = item.lastPlayedAt } } return tracks, 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.genre, ''), 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.Genre, &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(al.genre, ''), COALESCE(t.track_number, 0), COALESCE(t.duration_seconds, 0), COALESCE(t.bitrate_kbps, 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.genre, ''), 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.Genre, &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(al.genre, ''), COALESCE(t.track_number, 0), COALESCE(t.duration_seconds, 0), COALESCE(t.bitrate_kbps, 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.genre, ''), 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.Genre, &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(al.genre, ''), COALESCE(t.track_number, 0), COALESCE(t.duration_seconds, 0), COALESCE(t.bitrate_kbps, 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.Genre, &track.TrackNumber, &track.DurationSecs, &track.BitrateKbps, &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 }