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_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

View File

@@ -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

View File

@@ -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[:])
}

View File

@@ -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"),
}

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 {
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

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"
"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,
)

View File

@@ -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") {

View File

@@ -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()
}

View File

@@ -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{