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_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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[:])
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
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"
|
||||
|
||||
"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,
|
||||
)
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user