From b16f9de6c8f80a4d982ec01d266990e1cf29a232 Mon Sep 17 00:00:00 2001 From: benya Date: Thu, 2 Apr 2026 23:14:10 +0300 Subject: [PATCH] feat: add subsonic token auth and starred endpoints --- .env.example | 1 + SUBSONIC_SERVER_BLUEPRINT.md | 22 +-- internal/auth/service.go | 117 ++++++++++-- internal/config/config.go | 30 +-- internal/db/db.go | 29 ++- .../migrations/0002_subsonic_auth_secret.sql | 1 + internal/db/seed.go | 10 +- internal/httpapi/router.go | 82 ++++++++- internal/library/service.go | 171 ++++++++++++++++++ internal/subsonic/service.go | 140 +++++++++++--- 10 files changed, 535 insertions(+), 68 deletions(-) create mode 100644 internal/db/migrations/0002_subsonic_auth_secret.sql diff --git a/.env.example b/.env.example index edbcad8..326e6d5 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ APP_ENV=development SERVER_HOST=0.0.0.0 SERVER_PORT=4040 DATABASE_PATH=./data/app.db +APP_ENCRYPTION_KEY=change-me-for-production ARTWORK_CACHE_DIR=./data/artwork MEDIA_ROOT=./media CORS_ORIGINS=http://localhost:5173 diff --git a/SUBSONIC_SERVER_BLUEPRINT.md b/SUBSONIC_SERVER_BLUEPRINT.md index a1c427d..66b5e17 100644 --- a/SUBSONIC_SERVER_BLUEPRINT.md +++ b/SUBSONIC_SERVER_BLUEPRINT.md @@ -581,18 +581,18 @@ Responsibilities: ## Favorites - [x] Add favorites table -- [ ] Star track -- [ ] Unstar track -- [ ] Star album if desired -- [ ] Unstar album if desired -- [ ] Star artist if desired -- [ ] Unstar artist if desired +- [x] Star track +- [x] Unstar track +- [x] Star album if desired +- [x] Unstar album if desired +- [x] Star artist if desired +- [x] Unstar artist if desired ## Subsonic Compatibility - [x] Implement request auth parsing - [x] Support username/password auth where needed -- [ ] Support token/salt auth +- [x] Support token/salt auth - [x] Add common Subsonic response builder - [x] Implement `ping` - [x] Implement `getLicense` @@ -602,11 +602,11 @@ Responsibilities: - [x] Implement `getSong` - [x] Implement `stream` - [x] Implement `getCoverArt` -- [ ] Implement `search3` +- [x] Implement `search3` - [x] Implement `getRandomSongs` -- [ ] Implement `getStarred2` -- [ ] Implement `star` -- [ ] Implement `unstar` +- [x] Implement `getStarred2` +- [x] Implement `star` +- [x] Implement `unstar` - [ ] Implement playlist endpoints - [ ] Implement `scrobble` - [ ] Test against at least one existing Subsonic client diff --git a/internal/auth/service.go b/internal/auth/service.go index 9dd83ea..632ea20 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -2,11 +2,17 @@ package auth import ( "context" + "crypto/aes" + "crypto/cipher" + "crypto/md5" "crypto/rand" + "crypto/sha256" "database/sql" + "encoding/base64" "encoding/hex" "errors" "fmt" + "io" "strings" "time" @@ -32,15 +38,16 @@ type Session struct { } type Service struct { - db *sql.DB + db *sql.DB + encryptionKey string } -func NewService(db *sql.DB) *Service { - return &Service{db: db} +func NewService(db *sql.DB, encryptionKey string) *Service { + return &Service{db: db, encryptionKey: encryptionKey} } func (s *Service) Login(ctx context.Context, username, password string) (Session, error) { - user, passwordHash, err := s.findUserByUsername(ctx, username) + user, passwordHash, _, err := s.findUserByUsername(ctx, username) if err != nil { if errors.Is(err, sql.ErrNoRows) { return Session{}, ErrInvalidCredentials @@ -52,6 +59,10 @@ func (s *Service) Login(ctx context.Context, username, password string) (Session return Session{}, ErrInvalidCredentials } + if err := s.storeSubsonicSecret(ctx, user.ID, password); err != nil { + return Session{}, fmt.Errorf("store subsonic secret: %w", err) + } + token, err := newToken() if err != nil { return Session{}, fmt.Errorf("generate token: %w", err) @@ -112,7 +123,7 @@ func (s *Service) CurrentUserBySubsonicAuth(ctx context.Context, username, passw return User{}, ErrUnauthorized } - user, passwordHash, err := s.findUserByUsername(ctx, username) + user, passwordHash, encryptedSecret, err := s.findUserByUsername(ctx, username) if err != nil { if errors.Is(err, sql.ErrNoRows) { return User{}, ErrUnauthorized @@ -121,9 +132,17 @@ func (s *Service) CurrentUserBySubsonicAuth(ctx context.Context, username, passw } if token != "" || salt != "" { - // We only support legacy `p` auth right now because password hashes are stored using bcrypt - // and cannot be converted back into the plain password needed for Subsonic token auth. - return User{}, ErrUnauthorized + if token == "" || salt == "" || encryptedSecret == "" { + return User{}, ErrUnauthorized + } + secret, err := decryptSecret(encryptedSecret, s.encryptionKey) + if err != nil { + return User{}, ErrUnauthorized + } + if hexMD5(secret+salt) != strings.ToLower(token) { + return User{}, ErrUnauthorized + } + return user, nil } if strings.HasPrefix(password, "enc:") { @@ -138,19 +157,24 @@ func (s *Service) CurrentUserBySubsonicAuth(ctx context.Context, username, passw return User{}, ErrUnauthorized } + if err := s.storeSubsonicSecret(ctx, user.ID, password); err != nil { + return User{}, fmt.Errorf("store subsonic secret: %w", err) + } + return user, nil } -func (s *Service) findUserByUsername(ctx context.Context, username string) (User, string, error) { +func (s *Service) findUserByUsername(ctx context.Context, username string) (User, string, string, error) { var user User var passwordHash string + var subsonicSecret sql.NullString var createdAt string var lastLoginAt sql.NullString var isAdmin int err := s.db.QueryRowContext( ctx, - `SELECT id, username, password_hash, is_admin, created_at, last_login_at + `SELECT id, username, password_hash, COALESCE(subsonic_auth_secret, ''), is_admin, created_at, last_login_at FROM users WHERE username = ?`, username, @@ -158,12 +182,13 @@ func (s *Service) findUserByUsername(ctx context.Context, username string) (User &user.ID, &user.Username, &passwordHash, + &subsonicSecret, &isAdmin, &createdAt, &lastLoginAt, ) if err != nil { - return User{}, "", err + return User{}, "", "", err } user.IsAdmin = isAdmin == 1 @@ -172,7 +197,7 @@ func (s *Service) findUserByUsername(ctx context.Context, username string) (User user.LastLoginAt = parseTime(lastLoginAt.String) } - return user, passwordHash, nil + return user, passwordHash, subsonicSecret.String, nil } func (s *Service) findUserByToken(ctx context.Context, token string) (User, error) { @@ -224,3 +249,71 @@ func newToken() (string, error) { } return hex.EncodeToString(bytes), nil } + +func (s *Service) storeSubsonicSecret(ctx context.Context, userID, password string) error { + if userID == "" || password == "" { + return nil + } + encrypted, err := encryptSecret(password, s.encryptionKey) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, `UPDATE users SET subsonic_auth_secret = ? WHERE id = ?`, encrypted, userID) + return err +} + +func EncryptSubsonicSecret(value, key string) (string, error) { + return encryptSecret(value, key) +} + +func encryptSecret(value, key string) (string, error) { + block, err := aes.NewCipher(deriveKey(key)) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + ciphertext := gcm.Seal(nonce, nonce, []byte(value), nil) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +func decryptSecret(value, key string) (string, error) { + block, err := aes.NewCipher(deriveKey(key)) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + raw, err := base64.StdEncoding.DecodeString(value) + if err != nil { + return "", err + } + if len(raw) < gcm.NonceSize() { + return "", ErrUnauthorized + } + nonce := raw[:gcm.NonceSize()] + ciphertext := raw[gcm.NonceSize():] + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", err + } + return string(plaintext), nil +} + +func deriveKey(key string) []byte { + sum := sha256.Sum256([]byte(key)) + return sum[:] +} + +func hexMD5(value string) string { + sum := md5.Sum([]byte(value)) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/config/config.go b/internal/config/config.go index 39b55e5..65c4176 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,26 +3,28 @@ package config import "os" type Config struct { - AppEnv string - ServerHost string - ServerPort string - DatabasePath string - ArtworkCacheDir string - MediaRoot string - CORSOrigins string + AppEnv string + ServerHost string + ServerPort string + DatabasePath string + EncryptionKey string + ArtworkCacheDir string + MediaRoot string + CORSOrigins string DefaultAdminUsername string DefaultAdminPassword string } func Load() Config { return Config{ - AppEnv: getenv("APP_ENV", "development"), - ServerHost: getenv("SERVER_HOST", "0.0.0.0"), - ServerPort: getenv("SERVER_PORT", "4040"), - DatabasePath: getenv("DATABASE_PATH", "./data/app.db"), - ArtworkCacheDir: getenv("ARTWORK_CACHE_DIR", "./data/artwork"), - MediaRoot: getenv("MEDIA_ROOT", "./media"), - CORSOrigins: getenv("CORS_ORIGINS", "http://localhost:5173"), + AppEnv: getenv("APP_ENV", "development"), + ServerHost: getenv("SERVER_HOST", "0.0.0.0"), + ServerPort: getenv("SERVER_PORT", "4040"), + DatabasePath: getenv("DATABASE_PATH", "./data/app.db"), + EncryptionKey: getenv("APP_ENCRYPTION_KEY", "temporserv-dev-insecure-key"), + ArtworkCacheDir: getenv("ARTWORK_CACHE_DIR", "./data/artwork"), + MediaRoot: getenv("MEDIA_ROOT", "./media"), + CORSOrigins: getenv("CORS_ORIGINS", "http://localhost:5173"), DefaultAdminUsername: getenv("DEFAULT_ADMIN_USERNAME", "demo"), DefaultAdminPassword: getenv("DEFAULT_ADMIN_PASSWORD", "demo"), } diff --git a/internal/db/db.go b/internal/db/db.go index 3c193f6..ba7e8fb 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -43,6 +43,10 @@ func Open(ctx context.Context, cfg config.Config) (*sql.DB, error) { } func Migrate(ctx context.Context, database *sql.DB) error { + if _, err := database.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)`); err != nil { + return fmt.Errorf("create schema_migrations: %w", err) + } + entries, err := migrationFiles.ReadDir("migrations") if err != nil { return fmt.Errorf("read migrations: %w", err) @@ -57,14 +61,37 @@ func Migrate(ctx context.Context, database *sql.DB) error { continue } + var alreadyApplied int + if err := database.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE name = ?`, entry.Name()).Scan(&alreadyApplied); err != nil { + return fmt.Errorf("check migration %s: %w", entry.Name(), err) + } + if alreadyApplied > 0 { + continue + } + sqlBytes, err := migrationFiles.ReadFile("migrations/" + entry.Name()) if err != nil { return fmt.Errorf("read migration %s: %w", entry.Name(), err) } - if _, err := database.ExecContext(ctx, string(sqlBytes)); err != nil { + tx, err := database.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin migration %s: %w", entry.Name(), err) + } + + if _, err := tx.ExecContext(ctx, string(sqlBytes)); err != nil { + _ = tx.Rollback() return fmt.Errorf("apply migration %s: %w", entry.Name(), err) } + + if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)`, entry.Name(), time.Now().UTC().Format(time.RFC3339)); err != nil { + _ = tx.Rollback() + return fmt.Errorf("record migration %s: %w", entry.Name(), err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit migration %s: %w", entry.Name(), err) + } } return nil diff --git a/internal/db/migrations/0002_subsonic_auth_secret.sql b/internal/db/migrations/0002_subsonic_auth_secret.sql new file mode 100644 index 0000000..7922fc8 --- /dev/null +++ b/internal/db/migrations/0002_subsonic_auth_secret.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN subsonic_auth_secret TEXT; diff --git a/internal/db/seed.go b/internal/db/seed.go index 92574ab..2a4f0f7 100644 --- a/internal/db/seed.go +++ b/internal/db/seed.go @@ -11,6 +11,7 @@ import ( "golang.org/x/crypto/bcrypt" + "github.com/benya/temporserv/internal/auth" "github.com/benya/temporserv/internal/config" ) @@ -60,16 +61,21 @@ func seedAdmin(ctx context.Context, database *sql.DB, cfg config.Config) error { if err != nil { return fmt.Errorf("hash admin password: %w", err) } + subsonicSecret, err := auth.EncryptSubsonicSecret(cfg.DefaultAdminPassword, cfg.EncryptionKey) + if err != nil { + return fmt.Errorf("encrypt admin subsonic secret: %w", err) + } now := time.Now().UTC().Format(time.RFC3339) _, err = database.ExecContext( ctx, - `INSERT INTO users (id, username, password_hash, is_admin, created_at, last_login_at) - VALUES (?, ?, ?, 1, ?, ?)`, + `INSERT INTO users (id, username, password_hash, subsonic_auth_secret, is_admin, created_at, last_login_at) + VALUES (?, ?, ?, ?, 1, ?, ?)`, "user-admin", cfg.DefaultAdminUsername, string(passwordHash), + subsonicSecret, now, now, ) diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go index 3f977c8..ddb42a7 100644 --- a/internal/httpapi/router.go +++ b/internal/httpapi/router.go @@ -6,6 +6,7 @@ import ( "errors" "net/http" "os" + "strconv" "strings" "time" @@ -26,7 +27,7 @@ type app struct { func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service) http.Handler { application := app{ - auth: auth.NewService(database), + auth: auth.NewService(database, cfg.EncryptionKey), library: library.NewService(database), scanner: scanService, } @@ -80,6 +81,10 @@ func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service authed.Get("/getAlbum.view", application.subsonicAlbumByID) authed.Get("/getSong.view", application.subsonicSongByID) authed.Get("/getRandomSongs.view", application.subsonicRandomSongs) + authed.Get("/search3.view", application.subsonicSearch3) + authed.Get("/getStarred2.view", application.subsonicStarred2) + authed.Get("/star.view", application.subsonicStar) + authed.Get("/unstar.view", application.subsonicUnstar) authed.Get("/getScanStatus.view", application.subsonicScanStatus) authed.Get("/startScan.view", application.subsonicStartScan) authed.Get("/getCoverArt.view", application.subsonicCoverArt) @@ -222,7 +227,13 @@ func (a app) subsonicArtists(w http.ResponseWriter, r *http.Request) { } func (a app) subsonicRandomSongs(w http.ResponseWriter, r *http.Request) { - tracks, err := a.library.Tracks(r.Context(), 20) + size := 20 + if raw := strings.TrimSpace(r.URL.Query().Get("size")); raw != "" { + if parsed := parsePositiveInt(raw); parsed > 0 { + size = parsed + } + } + tracks, err := a.library.Tracks(r.Context(), size) if err != nil { writeJSON(w, http.StatusInternalServerError, subsonic.PingResponse()) return @@ -230,6 +241,54 @@ func (a app) subsonicRandomSongs(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, subsonic.RandomSongsResponse(tracks)) } +func (a app) subsonicSearch3(w http.ResponseWriter, r *http.Request) { + query := strings.TrimSpace(r.URL.Query().Get("query")) + if query == "" { + writeJSON(w, http.StatusOK, subsonic.Search3Response(library.SearchResults{})) + return + } + + count := parsePositiveInt(r.URL.Query().Get("songCount")) + if count == 0 { + count = 20 + } + + results, err := a.library.Search(r.Context(), query, count) + if err != nil { + writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "search failed")) + return + } + writeJSON(w, http.StatusOK, subsonic.Search3Response(results)) +} + +func (a app) subsonicStarred2(w http.ResponseWriter, r *http.Request) { + user := currentUserFromContext(r) + results, err := a.library.Starred(r.Context(), user.ID) + if err != nil { + writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to load starred items")) + return + } + writeJSON(w, http.StatusOK, subsonic.Starred2Response(results)) +} + +func (a app) subsonicStar(w http.ResponseWriter, r *http.Request) { + user := currentUserFromContext(r) + if err := a.library.Star(r.Context(), user.ID, readMultiValue(r, "id"), readMultiValue(r, "albumId"), readMultiValue(r, "artistId")); err != nil { + writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to star items")) + return + } + writeJSON(w, http.StatusOK, subsonic.PingResponse()) +} + +func (a app) subsonicUnstar(w http.ResponseWriter, r *http.Request) { + user := currentUserFromContext(r) + if err := a.library.Unstar(r.Context(), user.ID, readMultiValue(r, "id"), readMultiValue(r, "albumId"), readMultiValue(r, "artistId")); err != nil { + writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to unstar items")) + return + } + writeJSON(w, http.StatusOK, subsonic.PingResponse()) +} + 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 { @@ -401,6 +460,25 @@ func writeJSON(w http.ResponseWriter, status int, payload any) { _ = json.NewEncoder(w).Encode(payload) } +func readMultiValue(r *http.Request, key string) []string { + values := r.URL.Query()[key] + if len(values) > 0 { + return values + } + if single := strings.TrimSpace(r.URL.Query().Get(key)); single != "" { + return []string{single} + } + return nil +} + +func parsePositiveInt(raw string) int { + value, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || value < 1 { + return 0 + } + return value +} + func spaFallback(next http.Handler) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/api") || strings.HasPrefix(r.URL.Path, "/rest") || strings.HasPrefix(r.URL.Path, "/health") { diff --git a/internal/library/service.go b/internal/library/service.go index 3f68da2..d4f8a00 100644 --- a/internal/library/service.go +++ b/internal/library/service.go @@ -61,6 +61,12 @@ type SearchResults struct { 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 } @@ -340,6 +346,34 @@ func (s *Service) Search(ctx context.Context, query string, limit int) (SearchRe }, 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 @@ -545,3 +579,140 @@ func (s *Service) searchTracks(ctx context.Context, pattern string, limit int) ( } 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() +} diff --git a/internal/subsonic/service.go b/internal/subsonic/service.go index 9663bc7..7455d30 100644 --- a/internal/subsonic/service.go +++ b/internal/subsonic/service.go @@ -12,18 +12,20 @@ type Envelope struct { } type Response struct { - Status string `json:"status"` - Version string `json:"version"` - Type string `json:"type"` - 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"` + Status string `json:"status"` + Version string `json:"version"` + Type string `json:"type"` + 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"` + SearchResult3 *SearchResult3 `json:"searchResult3,omitempty"` + Starred2 *Starred2 `json:"starred2,omitempty"` + ScanStatus *ScanStatus `json:"scanStatus,omitempty"` + Error *ErrorRef `json:"error,omitempty"` } type ArtistRef struct { @@ -32,10 +34,13 @@ type ArtistRef struct { } type SongRef struct { - ID string `json:"id"` - Title string `json:"title"` - Album string `json:"album"` - Artist string `json:"artist"` + ID string `json:"id"` + Title string `json:"title"` + Album string `json:"album"` + Artist string `json:"artist"` + AlbumID string `json:"albumId,omitempty"` + ArtistID string `json:"artistId,omitempty"` + CoverArt string `json:"coverArt,omitempty"` } type ArtistFull struct { @@ -78,12 +83,24 @@ type SongFull struct { } type ScanStatus struct { - Scanning bool `json:"scanning"` - Count int `json:"count"` - FolderCount int `json:"folderCount"` - LastError string `json:"lastError,omitempty"` - StartedAt string `json:"startedAt,omitempty"` - FinishedAt string `json:"finishedAt,omitempty"` + Scanning bool `json:"scanning"` + Count int `json:"count"` + FolderCount int `json:"folderCount"` + LastError string `json:"lastError,omitempty"` + StartedAt string `json:"startedAt,omitempty"` + FinishedAt string `json:"finishedAt,omitempty"` +} + +type SearchResult3 struct { + Artist []ArtistRef `json:"artist,omitempty"` + Album []AlbumRef `json:"album,omitempty"` + Song []SongRef `json:"song,omitempty"` +} + +type Starred2 struct { + Artist []ArtistRef `json:"artist,omitempty"` + Album []AlbumRef `json:"album,omitempty"` + Song []SongRef `json:"song,omitempty"` } type ErrorRef struct { @@ -118,10 +135,13 @@ func RandomSongsResponse(tracks []library.Track) Envelope { response := PingResponse() for _, track := range tracks { response.SubsonicResponse.RandomSong = append(response.SubsonicResponse.RandomSong, SongRef{ - ID: track.ID, - Title: track.Title, - Album: track.AlbumTitle, - Artist: track.ArtistName, + ID: track.ID, + Title: track.Title, + Album: track.AlbumTitle, + Artist: track.ArtistName, + AlbumID: track.AlbumID, + ArtistID: track.ArtistID, + CoverArt: track.CoverArtID, }) } return response @@ -149,6 +169,74 @@ func ArtistResponse(artist library.ArtistDetail) Envelope { return response } +func Search3Response(results library.SearchResults) Envelope { + response := PingResponse() + payload := &SearchResult3{} + for _, artist := range results.Artists { + payload.Artist = append(payload.Artist, ArtistRef{ + ID: artist.ID, + Name: artist.Name, + }) + } + for _, album := range results.Albums { + payload.Album = append(payload.Album, AlbumRef{ + ID: album.ID, + Name: album.Title, + Artist: album.ArtistName, + ArtistID: album.ArtistID, + Year: album.Year, + CoverArt: album.CoverArtID, + }) + } + for _, track := range results.Tracks { + payload.Song = append(payload.Song, SongRef{ + ID: track.ID, + Title: track.Title, + Album: track.AlbumTitle, + Artist: track.ArtistName, + AlbumID: track.AlbumID, + ArtistID: track.ArtistID, + CoverArt: track.CoverArtID, + }) + } + response.SubsonicResponse.SearchResult3 = payload + return response +} + +func Starred2Response(results library.StarredResults) Envelope { + response := PingResponse() + payload := &Starred2{} + for _, artist := range results.Artists { + payload.Artist = append(payload.Artist, ArtistRef{ + ID: artist.ID, + Name: artist.Name, + }) + } + for _, album := range results.Albums { + payload.Album = append(payload.Album, AlbumRef{ + ID: album.ID, + Name: album.Title, + Artist: album.ArtistName, + ArtistID: album.ArtistID, + Year: album.Year, + CoverArt: album.CoverArtID, + }) + } + for _, track := range results.Tracks { + payload.Song = append(payload.Song, SongRef{ + ID: track.ID, + Title: track.Title, + Album: track.AlbumTitle, + Artist: track.ArtistName, + AlbumID: track.AlbumID, + ArtistID: track.ArtistID, + CoverArt: track.CoverArtID, + }) + } + response.SubsonicResponse.Starred2 = payload + return response +} + func AlbumResponse(album library.AlbumDetail) Envelope { response := PingResponse() item := &AlbumFull{