feat: add subsonic token auth and starred endpoints
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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[:])
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
1
internal/db/migrations/0002_subsonic_auth_secret.sql
Normal file
1
internal/db/migrations/0002_subsonic_auth_secret.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN subsonic_auth_secret TEXT;
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
Reference in New Issue
Block a user