diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go index 17b01b6..6c02452 100644 --- a/internal/httpapi/router.go +++ b/internal/httpapi/router.go @@ -51,7 +51,12 @@ func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service private.Use(application.requireAuth) private.Get("/me", application.me) private.Get("/home", application.home) + private.Get("/artists", application.artists) + private.Get("/artists/{id}", application.artistByID) + private.Get("/albums/{id}", application.albumByID) private.Get("/tracks", application.tracks) + private.Get("/tracks/{id}", application.trackByID) + private.Get("/search", application.search) private.Get("/admin/scan-status", application.scanStatus) private.Post("/admin/scan", application.scanLibrary) }) @@ -70,10 +75,14 @@ func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service rest.Group(func(authed chi.Router) { authed.Use(application.requireSubsonicAuth) authed.Get("/getArtists.view", application.subsonicArtists) + authed.Get("/getArtist.view", application.subsonicArtistByID) + authed.Get("/getAlbum.view", application.subsonicAlbumByID) + authed.Get("/getSong.view", application.subsonicSongByID) authed.Get("/getRandomSongs.view", application.subsonicRandomSongs) authed.Get("/getScanStatus.view", application.subsonicScanStatus) authed.Get("/startScan.view", application.subsonicStartScan) authed.Get("/getCoverArt.view", application.subsonicCoverArt) + authed.Get("/stream.view", application.subsonicStream) }) }) @@ -121,6 +130,41 @@ func (a app) home(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, home) } +func (a app) artists(w http.ResponseWriter, r *http.Request) { + items, err := a.library.Artists(r.Context(), 500) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load artists"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"items": items}) +} + +func (a app) artistByID(w http.ResponseWriter, r *http.Request) { + item, err := a.library.ArtistByID(r.Context(), chi.URLParam(r, "id")) + if err != nil { + if errors.Is(err, library.ErrNotFound) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "artist not found"}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load artist"}) + return + } + writeJSON(w, http.StatusOK, item) +} + +func (a app) albumByID(w http.ResponseWriter, r *http.Request) { + item, err := a.library.AlbumByID(r.Context(), chi.URLParam(r, "id")) + if err != nil { + if errors.Is(err, library.ErrNotFound) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "album not found"}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load album"}) + return + } + writeJSON(w, http.StatusOK, item) +} + func (a app) tracks(w http.ResponseWriter, r *http.Request) { items, err := a.library.Tracks(r.Context(), 200) if err != nil { @@ -130,6 +174,34 @@ func (a app) tracks(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"items": items}) } +func (a app) trackByID(w http.ResponseWriter, r *http.Request) { + item, err := a.library.TrackByID(r.Context(), chi.URLParam(r, "id")) + if err != nil { + if errors.Is(err, library.ErrNotFound) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "track not found"}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load track"}) + return + } + writeJSON(w, http.StatusOK, item) +} + +func (a app) search(w http.ResponseWriter, r *http.Request) { + query := strings.TrimSpace(r.URL.Query().Get("q")) + if query == "" { + writeJSON(w, http.StatusOK, library.SearchResults{}) + return + } + + results, err := a.library.Search(r.Context(), query, 20) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "search failed"}) + return + } + writeJSON(w, http.StatusOK, results) +} + func (a app) subsonicArtists(w http.ResponseWriter, r *http.Request) { artists, err := a.library.Artists(r.Context(), 1000) if err != nil { @@ -148,6 +220,45 @@ func (a app) subsonicRandomSongs(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, subsonic.RandomSongsResponse(tracks)) } +func (a app) subsonicArtistByID(w http.ResponseWriter, r *http.Request) { + item, err := a.library.ArtistByID(r.Context(), r.URL.Query().Get("id")) + if err != nil { + if errors.Is(err, library.ErrNotFound) { + writeJSON(w, http.StatusNotFound, subsonic.ErrorResponse(70, "artist not found")) + return + } + writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to load artist")) + return + } + writeJSON(w, http.StatusOK, subsonic.ArtistResponse(item)) +} + +func (a app) subsonicAlbumByID(w http.ResponseWriter, r *http.Request) { + item, err := a.library.AlbumByID(r.Context(), r.URL.Query().Get("id")) + if err != nil { + if errors.Is(err, library.ErrNotFound) { + writeJSON(w, http.StatusNotFound, subsonic.ErrorResponse(70, "album not found")) + return + } + writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to load album")) + return + } + writeJSON(w, http.StatusOK, subsonic.AlbumResponse(item)) +} + +func (a app) subsonicSongByID(w http.ResponseWriter, r *http.Request) { + item, err := a.library.TrackByID(r.Context(), r.URL.Query().Get("id")) + if err != nil { + if errors.Is(err, library.ErrNotFound) { + writeJSON(w, http.StatusNotFound, subsonic.ErrorResponse(70, "song not found")) + return + } + writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to load song")) + return + } + writeJSON(w, http.StatusOK, subsonic.SongResponse(item)) +} + func (a app) scanLibrary(w http.ResponseWriter, r *http.Request) { result, err := a.scanner.Scan(r.Context()) if err != nil { @@ -183,28 +294,7 @@ func (a app) streamTrack(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) return } - - track, err := a.library.TrackByID(r.Context(), chi.URLParam(r, "id")) - if err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "track not found"}) - return - } - - file, err := os.Open(track.FilePath) - if err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "audio file not available"}) - return - } - defer file.Close() - - info, err := file.Stat() - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to stat file"}) - return - } - - w.Header().Set("Content-Type", track.ContentType) - http.ServeContent(w, r, info.Name(), info.ModTime(), file) + a.serveTrackByID(w, r, chi.URLParam(r, "id")) } func (a app) subsonicScanStatus(w http.ResponseWriter, r *http.Request) { @@ -223,6 +313,10 @@ func (a app) subsonicCoverArt(w http.ResponseWriter, r *http.Request) { a.serveCoverArtByID(w, r, r.URL.Query().Get("id")) } +func (a app) subsonicStream(w http.ResponseWriter, r *http.Request) { + a.serveTrackByID(w, r, r.URL.Query().Get("id")) +} + func (a app) serveCoverArtByID(w http.ResponseWriter, r *http.Request, id string) { path, err := a.library.CoverArtPathByEntityID(r.Context(), id) if err != nil { @@ -251,6 +345,34 @@ func (a app) serveCoverArtByID(w http.ResponseWriter, r *http.Request, id string http.ServeContent(w, r, info.Name(), info.ModTime(), file) } +func (a app) serveTrackByID(w http.ResponseWriter, r *http.Request, id string) { + track, err := a.library.TrackByID(r.Context(), id) + if err != nil { + if errors.Is(err, library.ErrNotFound) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "track not found"}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load track"}) + return + } + + file, err := os.Open(track.FilePath) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "audio file not available"}) + return + } + defer file.Close() + + info, err := file.Stat() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to stat file"}) + return + } + + w.Header().Set("Content-Type", track.ContentType) + http.ServeContent(w, r, info.Name(), info.ModTime(), file) +} + func detectImageContentType(path string) string { lower := strings.ToLower(path) switch { diff --git a/internal/library/service.go b/internal/library/service.go index 9efdc6f..383cacb 100644 --- a/internal/library/service.go +++ b/internal/library/service.go @@ -13,6 +13,7 @@ type Artist struct { ID string `json:"id"` Name string `json:"name"` AlbumCount int `json:"albumCount"` + CoverArtID string `json:"coverArtId"` } type Album struct { @@ -44,6 +45,22 @@ type HomePayload struct { 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 Service struct { db *sql.DB } @@ -73,6 +90,7 @@ 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 @@ -88,7 +106,7 @@ func (s *Service) Artists(ctx context.Context, limit int) ([]Artist, error) { var artists []Artist for rows.Next() { var artist Artist - if err := rows.Scan(&artist.ID, &artist.Name, &artist.AlbumCount); err != nil { + 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) @@ -97,6 +115,34 @@ func (s *Service) Artists(ctx context.Context, limit int) ([]Artist, error) { 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, @@ -127,6 +173,44 @@ func (s *Service) RecentAlbums(ctx context.Context, limit int) ([]Album, error) 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, @@ -194,12 +278,40 @@ func (s *Service) TrackByID(ctx context.Context, id string) (Track, error) { &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) CoverArtPathByEntityID(ctx context.Context, id string) (string, error) { var path string @@ -231,3 +343,177 @@ func (s *Service) CoverArtPathByEntityID(ctx context.Context, id string) (string } 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() +} diff --git a/internal/subsonic/service.go b/internal/subsonic/service.go index 319a937..9663bc7 100644 --- a/internal/subsonic/service.go +++ b/internal/subsonic/service.go @@ -18,6 +18,9 @@ type Response struct { Server string `json:"serverVersion"` OpenAPI bool `json:"openSubsonic"` Artists []ArtistRef `json:"artists,omitempty"` + Artist *ArtistFull `json:"artist,omitempty"` + Album *AlbumFull `json:"album,omitempty"` + Song *SongFull `json:"song,omitempty"` RandomSong []SongRef `json:"randomSongs,omitempty"` ScanStatus *ScanStatus `json:"scanStatus,omitempty"` Error *ErrorRef `json:"error,omitempty"` @@ -35,6 +38,45 @@ type SongRef struct { Artist string `json:"artist"` } +type ArtistFull struct { + ID string `json:"id"` + Name string `json:"name"` + CoverArt string `json:"coverArt,omitempty"` + AlbumCount int `json:"albumCount,omitempty"` + Albums []AlbumRef `json:"album,omitempty"` +} + +type AlbumRef struct { + ID string `json:"id"` + Name string `json:"name"` + Artist string `json:"artist"` + ArtistID string `json:"artistId"` + Year int `json:"year,omitempty"` + CoverArt string `json:"coverArt,omitempty"` +} + +type AlbumFull struct { + ID string `json:"id"` + Name string `json:"name"` + Artist string `json:"artist"` + ArtistID string `json:"artistId"` + Year int `json:"year,omitempty"` + CoverArt string `json:"coverArt,omitempty"` + Song []SongRef `json:"song,omitempty"` +} + +type SongFull struct { + ID string `json:"id"` + Title string `json:"title"` + Album string `json:"album"` + Artist string `json:"artist"` + AlbumID string `json:"albumId"` + ArtistID string `json:"artistId"` + Track int `json:"track,omitempty"` + Duration int `json:"duration,omitempty"` + CoverArt string `json:"coverArt,omitempty"` +} + type ScanStatus struct { Scanning bool `json:"scanning"` Count int `json:"count"` @@ -85,6 +127,66 @@ func RandomSongsResponse(tracks []library.Track) Envelope { return response } +func ArtistResponse(artist library.ArtistDetail) Envelope { + response := PingResponse() + item := &ArtistFull{ + ID: artist.ID, + Name: artist.Name, + CoverArt: artist.CoverArtID, + AlbumCount: artist.AlbumCount, + } + for _, album := range artist.Albums { + item.Albums = append(item.Albums, AlbumRef{ + ID: album.ID, + Name: album.Title, + Artist: album.ArtistName, + ArtistID: album.ArtistID, + Year: album.Year, + CoverArt: album.CoverArtID, + }) + } + response.SubsonicResponse.Artist = item + return response +} + +func AlbumResponse(album library.AlbumDetail) Envelope { + response := PingResponse() + item := &AlbumFull{ + ID: album.ID, + Name: album.Title, + Artist: album.ArtistName, + ArtistID: album.ArtistID, + Year: album.Year, + CoverArt: album.CoverArtID, + } + for _, track := range album.Tracks { + item.Song = append(item.Song, SongRef{ + ID: track.ID, + Title: track.Title, + Album: track.AlbumTitle, + Artist: track.ArtistName, + }) + } + response.SubsonicResponse.Album = item + return response +} + +func SongResponse(track library.Track) Envelope { + response := PingResponse() + response.SubsonicResponse.Song = &SongFull{ + ID: track.ID, + Title: track.Title, + Album: track.AlbumTitle, + Artist: track.ArtistName, + AlbumID: track.AlbumID, + ArtistID: track.ArtistID, + Track: track.TrackNumber, + Duration: track.DurationSecs, + CoverArt: track.CoverArtID, + } + return response +} + func ScanStatusResponse(status scanner.Status) Envelope { response := PingResponse() response.SubsonicResponse.ScanStatus = &ScanStatus{