package library import ( "context" "database/sql" "errors" "fmt" ) 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"` 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 } return HomePayload{ RecentAlbums: albums, 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) 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() 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 tracks by album: %w", err) } tracks = append(tracks, track) } return tracks, rows.Err() } 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() 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 searched track: %w", err) } tracks = append(tracks, track) } return tracks, rows.Err() } 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() 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 starred track: %w", err) } tracks = append(tracks, track) } return tracks, rows.Err() }