feat: add subsonic token auth and starred endpoints

This commit is contained in:
2026-04-02 23:14:10 +03:00
parent a640251e7d
commit b16f9de6c8
10 changed files with 535 additions and 68 deletions

View File

@@ -2,6 +2,7 @@ APP_ENV=development
SERVER_HOST=0.0.0.0 SERVER_HOST=0.0.0.0
SERVER_PORT=4040 SERVER_PORT=4040
DATABASE_PATH=./data/app.db DATABASE_PATH=./data/app.db
APP_ENCRYPTION_KEY=change-me-for-production
ARTWORK_CACHE_DIR=./data/artwork ARTWORK_CACHE_DIR=./data/artwork
MEDIA_ROOT=./media MEDIA_ROOT=./media
CORS_ORIGINS=http://localhost:5173 CORS_ORIGINS=http://localhost:5173

View File

@@ -581,18 +581,18 @@ Responsibilities:
## Favorites ## Favorites
- [x] Add favorites table - [x] Add favorites table
- [ ] Star track - [x] Star track
- [ ] Unstar track - [x] Unstar track
- [ ] Star album if desired - [x] Star album if desired
- [ ] Unstar album if desired - [x] Unstar album if desired
- [ ] Star artist if desired - [x] Star artist if desired
- [ ] Unstar artist if desired - [x] Unstar artist if desired
## Subsonic Compatibility ## Subsonic Compatibility
- [x] Implement request auth parsing - [x] Implement request auth parsing
- [x] Support username/password auth where needed - [x] Support username/password auth where needed
- [ ] Support token/salt auth - [x] Support token/salt auth
- [x] Add common Subsonic response builder - [x] Add common Subsonic response builder
- [x] Implement `ping` - [x] Implement `ping`
- [x] Implement `getLicense` - [x] Implement `getLicense`
@@ -602,11 +602,11 @@ Responsibilities:
- [x] Implement `getSong` - [x] Implement `getSong`
- [x] Implement `stream` - [x] Implement `stream`
- [x] Implement `getCoverArt` - [x] Implement `getCoverArt`
- [ ] Implement `search3` - [x] Implement `search3`
- [x] Implement `getRandomSongs` - [x] Implement `getRandomSongs`
- [ ] Implement `getStarred2` - [x] Implement `getStarred2`
- [ ] Implement `star` - [x] Implement `star`
- [ ] Implement `unstar` - [x] Implement `unstar`
- [ ] Implement playlist endpoints - [ ] Implement playlist endpoints
- [ ] Implement `scrobble` - [ ] Implement `scrobble`
- [ ] Test against at least one existing Subsonic client - [ ] Test against at least one existing Subsonic client

View File

@@ -2,11 +2,17 @@ package auth
import ( import (
"context" "context"
"crypto/aes"
"crypto/cipher"
"crypto/md5"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"database/sql" "database/sql"
"encoding/base64"
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt" "fmt"
"io"
"strings" "strings"
"time" "time"
@@ -32,15 +38,16 @@ type Session struct {
} }
type Service struct { type Service struct {
db *sql.DB db *sql.DB
encryptionKey string
} }
func NewService(db *sql.DB) *Service { func NewService(db *sql.DB, encryptionKey string) *Service {
return &Service{db: db} return &Service{db: db, encryptionKey: encryptionKey}
} }
func (s *Service) Login(ctx context.Context, username, password string) (Session, error) { 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 err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return Session{}, ErrInvalidCredentials return Session{}, ErrInvalidCredentials
@@ -52,6 +59,10 @@ func (s *Service) Login(ctx context.Context, username, password string) (Session
return Session{}, ErrInvalidCredentials 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() token, err := newToken()
if err != nil { if err != nil {
return Session{}, fmt.Errorf("generate token: %w", err) return Session{}, fmt.Errorf("generate token: %w", err)
@@ -112,7 +123,7 @@ func (s *Service) CurrentUserBySubsonicAuth(ctx context.Context, username, passw
return User{}, ErrUnauthorized return User{}, ErrUnauthorized
} }
user, passwordHash, err := s.findUserByUsername(ctx, username) user, passwordHash, encryptedSecret, err := s.findUserByUsername(ctx, username)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return User{}, ErrUnauthorized return User{}, ErrUnauthorized
@@ -121,9 +132,17 @@ func (s *Service) CurrentUserBySubsonicAuth(ctx context.Context, username, passw
} }
if token != "" || salt != "" { if token != "" || salt != "" {
// We only support legacy `p` auth right now because password hashes are stored using bcrypt if token == "" || salt == "" || encryptedSecret == "" {
// and cannot be converted back into the plain password needed for Subsonic token auth. return User{}, ErrUnauthorized
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:") { if strings.HasPrefix(password, "enc:") {
@@ -138,19 +157,24 @@ func (s *Service) CurrentUserBySubsonicAuth(ctx context.Context, username, passw
return User{}, ErrUnauthorized 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 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 user User
var passwordHash string var passwordHash string
var subsonicSecret sql.NullString
var createdAt string var createdAt string
var lastLoginAt sql.NullString var lastLoginAt sql.NullString
var isAdmin int var isAdmin int
err := s.db.QueryRowContext( err := s.db.QueryRowContext(
ctx, 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 FROM users
WHERE username = ?`, WHERE username = ?`,
username, username,
@@ -158,12 +182,13 @@ func (s *Service) findUserByUsername(ctx context.Context, username string) (User
&user.ID, &user.ID,
&user.Username, &user.Username,
&passwordHash, &passwordHash,
&subsonicSecret,
&isAdmin, &isAdmin,
&createdAt, &createdAt,
&lastLoginAt, &lastLoginAt,
) )
if err != nil { if err != nil {
return User{}, "", err return User{}, "", "", err
} }
user.IsAdmin = isAdmin == 1 user.IsAdmin = isAdmin == 1
@@ -172,7 +197,7 @@ func (s *Service) findUserByUsername(ctx context.Context, username string) (User
user.LastLoginAt = parseTime(lastLoginAt.String) 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) { func (s *Service) findUserByToken(ctx context.Context, token string) (User, error) {
@@ -224,3 +249,71 @@ func newToken() (string, error) {
} }
return hex.EncodeToString(bytes), nil 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[:])
}

View File

@@ -3,26 +3,28 @@ package config
import "os" import "os"
type Config struct { type Config struct {
AppEnv string AppEnv string
ServerHost string ServerHost string
ServerPort string ServerPort string
DatabasePath string DatabasePath string
ArtworkCacheDir string EncryptionKey string
MediaRoot string ArtworkCacheDir string
CORSOrigins string MediaRoot string
CORSOrigins string
DefaultAdminUsername string DefaultAdminUsername string
DefaultAdminPassword string DefaultAdminPassword string
} }
func Load() Config { func Load() Config {
return Config{ return Config{
AppEnv: getenv("APP_ENV", "development"), AppEnv: getenv("APP_ENV", "development"),
ServerHost: getenv("SERVER_HOST", "0.0.0.0"), ServerHost: getenv("SERVER_HOST", "0.0.0.0"),
ServerPort: getenv("SERVER_PORT", "4040"), ServerPort: getenv("SERVER_PORT", "4040"),
DatabasePath: getenv("DATABASE_PATH", "./data/app.db"), DatabasePath: getenv("DATABASE_PATH", "./data/app.db"),
ArtworkCacheDir: getenv("ARTWORK_CACHE_DIR", "./data/artwork"), EncryptionKey: getenv("APP_ENCRYPTION_KEY", "temporserv-dev-insecure-key"),
MediaRoot: getenv("MEDIA_ROOT", "./media"), ArtworkCacheDir: getenv("ARTWORK_CACHE_DIR", "./data/artwork"),
CORSOrigins: getenv("CORS_ORIGINS", "http://localhost:5173"), MediaRoot: getenv("MEDIA_ROOT", "./media"),
CORSOrigins: getenv("CORS_ORIGINS", "http://localhost:5173"),
DefaultAdminUsername: getenv("DEFAULT_ADMIN_USERNAME", "demo"), DefaultAdminUsername: getenv("DEFAULT_ADMIN_USERNAME", "demo"),
DefaultAdminPassword: getenv("DEFAULT_ADMIN_PASSWORD", "demo"), DefaultAdminPassword: getenv("DEFAULT_ADMIN_PASSWORD", "demo"),
} }

View File

@@ -43,6 +43,10 @@ func Open(ctx context.Context, cfg config.Config) (*sql.DB, error) {
} }
func Migrate(ctx context.Context, database *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") entries, err := migrationFiles.ReadDir("migrations")
if err != nil { if err != nil {
return fmt.Errorf("read migrations: %w", err) return fmt.Errorf("read migrations: %w", err)
@@ -57,14 +61,37 @@ func Migrate(ctx context.Context, database *sql.DB) error {
continue 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()) sqlBytes, err := migrationFiles.ReadFile("migrations/" + entry.Name())
if err != nil { if err != nil {
return fmt.Errorf("read migration %s: %w", entry.Name(), err) 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) 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 return nil

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN subsonic_auth_secret TEXT;

View File

@@ -11,6 +11,7 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"github.com/benya/temporserv/internal/auth"
"github.com/benya/temporserv/internal/config" "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 { if err != nil {
return fmt.Errorf("hash admin password: %w", err) 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) now := time.Now().UTC().Format(time.RFC3339)
_, err = database.ExecContext( _, err = database.ExecContext(
ctx, ctx,
`INSERT INTO users (id, username, password_hash, is_admin, created_at, last_login_at) `INSERT INTO users (id, username, password_hash, subsonic_auth_secret, is_admin, created_at, last_login_at)
VALUES (?, ?, ?, 1, ?, ?)`, VALUES (?, ?, ?, ?, 1, ?, ?)`,
"user-admin", "user-admin",
cfg.DefaultAdminUsername, cfg.DefaultAdminUsername,
string(passwordHash), string(passwordHash),
subsonicSecret,
now, now,
now, now,
) )

View File

@@ -6,6 +6,7 @@ import (
"errors" "errors"
"net/http" "net/http"
"os" "os"
"strconv"
"strings" "strings"
"time" "time"
@@ -26,7 +27,7 @@ type app struct {
func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service) http.Handler { func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service) http.Handler {
application := app{ application := app{
auth: auth.NewService(database), auth: auth.NewService(database, cfg.EncryptionKey),
library: library.NewService(database), library: library.NewService(database),
scanner: scanService, 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("/getAlbum.view", application.subsonicAlbumByID)
authed.Get("/getSong.view", application.subsonicSongByID) authed.Get("/getSong.view", application.subsonicSongByID)
authed.Get("/getRandomSongs.view", application.subsonicRandomSongs) 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("/getScanStatus.view", application.subsonicScanStatus)
authed.Get("/startScan.view", application.subsonicStartScan) authed.Get("/startScan.view", application.subsonicStartScan)
authed.Get("/getCoverArt.view", application.subsonicCoverArt) 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) { 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 { if err != nil {
writeJSON(w, http.StatusInternalServerError, subsonic.PingResponse()) writeJSON(w, http.StatusInternalServerError, subsonic.PingResponse())
return return
@@ -230,6 +241,54 @@ func (a app) subsonicRandomSongs(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, subsonic.RandomSongsResponse(tracks)) 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) { func (a app) subsonicArtistByID(w http.ResponseWriter, r *http.Request) {
item, err := a.library.ArtistByID(r.Context(), r.URL.Query().Get("id")) item, err := a.library.ArtistByID(r.Context(), r.URL.Query().Get("id"))
if err != nil { if err != nil {
@@ -401,6 +460,25 @@ func writeJSON(w http.ResponseWriter, status int, payload any) {
_ = json.NewEncoder(w).Encode(payload) _ = 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 { func spaFallback(next http.Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { 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") { if strings.HasPrefix(r.URL.Path, "/api") || strings.HasPrefix(r.URL.Path, "/rest") || strings.HasPrefix(r.URL.Path, "/health") {

View File

@@ -61,6 +61,12 @@ type SearchResults struct {
Tracks []Track `json:"tracks"` Tracks []Track `json:"tracks"`
} }
type StarredResults struct {
Artists []Artist `json:"artists"`
Albums []Album `json:"albums"`
Tracks []Track `json:"tracks"`
}
type Service struct { type Service struct {
db *sql.DB db *sql.DB
} }
@@ -340,6 +346,34 @@ func (s *Service) Search(ctx context.Context, query string, limit int) (SearchRe
}, nil }, 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) { func (s *Service) CoverArtPathByEntityID(ctx context.Context, id string) (string, error) {
var path string var path string
@@ -545,3 +579,140 @@ func (s *Service) searchTracks(ctx context.Context, pattern string, limit int) (
} }
return tracks, rows.Err() 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()
}

View File

@@ -12,18 +12,20 @@ type Envelope struct {
} }
type Response struct { type Response struct {
Status string `json:"status"` Status string `json:"status"`
Version string `json:"version"` Version string `json:"version"`
Type string `json:"type"` Type string `json:"type"`
Server string `json:"serverVersion"` Server string `json:"serverVersion"`
OpenAPI bool `json:"openSubsonic"` OpenAPI bool `json:"openSubsonic"`
Artists []ArtistRef `json:"artists,omitempty"` Artists []ArtistRef `json:"artists,omitempty"`
Artist *ArtistFull `json:"artist,omitempty"` Artist *ArtistFull `json:"artist,omitempty"`
Album *AlbumFull `json:"album,omitempty"` Album *AlbumFull `json:"album,omitempty"`
Song *SongFull `json:"song,omitempty"` Song *SongFull `json:"song,omitempty"`
RandomSong []SongRef `json:"randomSongs,omitempty"` RandomSong []SongRef `json:"randomSongs,omitempty"`
ScanStatus *ScanStatus `json:"scanStatus,omitempty"` SearchResult3 *SearchResult3 `json:"searchResult3,omitempty"`
Error *ErrorRef `json:"error,omitempty"` Starred2 *Starred2 `json:"starred2,omitempty"`
ScanStatus *ScanStatus `json:"scanStatus,omitempty"`
Error *ErrorRef `json:"error,omitempty"`
} }
type ArtistRef struct { type ArtistRef struct {
@@ -32,10 +34,13 @@ type ArtistRef struct {
} }
type SongRef struct { type SongRef struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Album string `json:"album"` Album string `json:"album"`
Artist string `json:"artist"` Artist string `json:"artist"`
AlbumID string `json:"albumId,omitempty"`
ArtistID string `json:"artistId,omitempty"`
CoverArt string `json:"coverArt,omitempty"`
} }
type ArtistFull struct { type ArtistFull struct {
@@ -78,12 +83,24 @@ type SongFull struct {
} }
type ScanStatus struct { type ScanStatus struct {
Scanning bool `json:"scanning"` Scanning bool `json:"scanning"`
Count int `json:"count"` Count int `json:"count"`
FolderCount int `json:"folderCount"` FolderCount int `json:"folderCount"`
LastError string `json:"lastError,omitempty"` LastError string `json:"lastError,omitempty"`
StartedAt string `json:"startedAt,omitempty"` StartedAt string `json:"startedAt,omitempty"`
FinishedAt string `json:"finishedAt,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 { type ErrorRef struct {
@@ -118,10 +135,13 @@ func RandomSongsResponse(tracks []library.Track) Envelope {
response := PingResponse() response := PingResponse()
for _, track := range tracks { for _, track := range tracks {
response.SubsonicResponse.RandomSong = append(response.SubsonicResponse.RandomSong, SongRef{ response.SubsonicResponse.RandomSong = append(response.SubsonicResponse.RandomSong, SongRef{
ID: track.ID, ID: track.ID,
Title: track.Title, Title: track.Title,
Album: track.AlbumTitle, Album: track.AlbumTitle,
Artist: track.ArtistName, Artist: track.ArtistName,
AlbumID: track.AlbumID,
ArtistID: track.ArtistID,
CoverArt: track.CoverArtID,
}) })
} }
return response return response
@@ -149,6 +169,74 @@ func ArtistResponse(artist library.ArtistDetail) Envelope {
return response 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 { func AlbumResponse(album library.AlbumDetail) Envelope {
response := PingResponse() response := PingResponse()
item := &AlbumFull{ item := &AlbumFull{