diff --git a/.gitignore b/.gitignore
index 3a25a46..e0b9efe 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@ build/
.vite/
*.tsbuildinfo
data/
+media/
server.exe
.DS_Store
.env
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
index 45aac13..9a4a764 100644
--- a/apps/web/src/lib/api.ts
+++ b/apps/web/src/lib/api.ts
@@ -14,6 +14,7 @@ export type HomePayload = {
title: string
year: number
trackCount: number
+ coverArtId: string
}>
artists: Array<{
id: string
@@ -67,3 +68,8 @@ export async function fetchHome() {
export async function fetchTracks() {
return request<{ items: Track[] }>('/api/tracks')
}
+
+export function coverArtUrl(id: string) {
+ const token = useSessionStore.getState().token
+ return `${API_BASE}/api/cover-art/${id}${token ? `?token=${encodeURIComponent(token)}` : ''}`
+}
diff --git a/apps/web/src/pages/home-page.tsx b/apps/web/src/pages/home-page.tsx
index a8d207b..d520198 100644
--- a/apps/web/src/pages/home-page.tsx
+++ b/apps/web/src/pages/home-page.tsx
@@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query'
-import { fetchHome } from '@/lib/api'
+import { coverArtUrl, fetchHome } from '@/lib/api'
import { SectionTitle } from '@/components/section-title'
export function HomePage() {
@@ -28,7 +28,15 @@ export function HomePage() {
{home?.recentAlbums.map((album) => (
-
+ {album.coverArtId ? (
+
+ ) : (
+
+ )}
{album.title}
{album.artistName}
@@ -57,4 +65,3 @@ export function HomePage() {
)
}
-
diff --git a/internal/auth/service.go b/internal/auth/service.go
index ee27e76..9dd83ea 100644
--- a/internal/auth/service.go
+++ b/internal/auth/service.go
@@ -107,6 +107,40 @@ func (s *Service) CurrentUserByToken(ctx context.Context, token string) (User, e
return user, nil
}
+func (s *Service) CurrentUserBySubsonicAuth(ctx context.Context, username, password, token, salt string) (User, error) {
+ if username == "" {
+ return User{}, ErrUnauthorized
+ }
+
+ user, passwordHash, err := s.findUserByUsername(ctx, username)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return User{}, ErrUnauthorized
+ }
+ return User{}, fmt.Errorf("find user by username: %w", err)
+ }
+
+ 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 strings.HasPrefix(password, "enc:") {
+ decoded, err := hex.DecodeString(strings.TrimPrefix(password, "enc:"))
+ if err != nil {
+ return User{}, ErrUnauthorized
+ }
+ password = string(decoded)
+ }
+
+ if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)); err != nil {
+ return User{}, ErrUnauthorized
+ }
+
+ return user, nil
+}
+
func (s *Service) findUserByUsername(ctx context.Context, username string) (User, string, error) {
var user User
var passwordHash string
diff --git a/internal/httpapi/middleware.go b/internal/httpapi/middleware.go
index 4b05be2..701446c 100644
--- a/internal/httpapi/middleware.go
+++ b/internal/httpapi/middleware.go
@@ -79,3 +79,34 @@ func currentUserFromContext(r *http.Request) auth.User {
}
return user
}
+
+func (a app) requireSubsonicAuth(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ user, err := a.auth.CurrentUserBySubsonicAuth(
+ r.Context(),
+ r.URL.Query().Get("u"),
+ r.URL.Query().Get("p"),
+ r.URL.Query().Get("t"),
+ r.URL.Query().Get("s"),
+ )
+ if err != nil {
+ writeJSON(w, http.StatusUnauthorized, map[string]any{
+ "subsonic-response": map[string]any{
+ "status": "failed",
+ "version": "1.16.1",
+ "type": "temporserv",
+ "serverVersion": "0.1.0",
+ "openSubsonic": true,
+ "error": map[string]any{
+ "code": 40,
+ "message": "Wrong username or password",
+ },
+ },
+ })
+ return
+ }
+
+ ctx := context.WithValue(r.Context(), currentUserKey, user)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+}
diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go
index e89c702..17b01b6 100644
--- a/internal/httpapi/router.go
+++ b/internal/httpapi/router.go
@@ -52,9 +52,11 @@ func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service
private.Get("/me", application.me)
private.Get("/home", application.home)
private.Get("/tracks", application.tracks)
+ private.Get("/admin/scan-status", application.scanStatus)
private.Post("/admin/scan", application.scanLibrary)
})
+ api.Get("/cover-art/{id}", application.coverArt)
api.Get("/stream/{id}", application.streamTrack)
})
@@ -65,8 +67,14 @@ func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service
rest.Get("/getLicense.view", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, subsonic.PingResponse())
})
- rest.Get("/getArtists.view", application.subsonicArtists)
- rest.Get("/getRandomSongs.view", application.subsonicRandomSongs)
+ rest.Group(func(authed chi.Router) {
+ authed.Use(application.requireSubsonicAuth)
+ authed.Get("/getArtists.view", application.subsonicArtists)
+ authed.Get("/getRandomSongs.view", application.subsonicRandomSongs)
+ authed.Get("/getScanStatus.view", application.subsonicScanStatus)
+ authed.Get("/startScan.view", application.subsonicStartScan)
+ authed.Get("/getCoverArt.view", application.subsonicCoverArt)
+ })
})
fs := http.FileServer(http.Dir("./web"))
@@ -149,6 +157,22 @@ func (a app) scanLibrary(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, result)
}
+func (a app) scanStatus(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, http.StatusOK, a.scanner.Status())
+}
+
+func (a app) coverArt(w http.ResponseWriter, r *http.Request) {
+ token := r.URL.Query().Get("token")
+ if token == "" {
+ token = strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
+ }
+ if _, err := a.auth.CurrentUserByToken(r.Context(), token); err != nil {
+ writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
+ return
+ }
+ a.serveCoverArtByID(w, r, chi.URLParam(r, "id"))
+}
+
func (a app) streamTrack(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if token == "" {
@@ -183,6 +207,62 @@ func (a app) streamTrack(w http.ResponseWriter, r *http.Request) {
http.ServeContent(w, r, info.Name(), info.ModTime(), file)
}
+func (a app) subsonicScanStatus(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, http.StatusOK, subsonic.ScanStatusResponse(a.scanner.Status()))
+}
+
+func (a app) subsonicStartScan(w http.ResponseWriter, r *http.Request) {
+ if started := a.scanner.ScanAsync(r.Context()); !started {
+ writeJSON(w, http.StatusConflict, subsonic.ErrorResponse(0, "scan already running"))
+ return
+ }
+ writeJSON(w, http.StatusOK, subsonic.ScanStatusResponse(a.scanner.Status()))
+}
+
+func (a app) subsonicCoverArt(w http.ResponseWriter, r *http.Request) {
+ a.serveCoverArtByID(w, r, r.URL.Query().Get("id"))
+}
+
+func (a app) serveCoverArtByID(w http.ResponseWriter, r *http.Request, id string) {
+ path, err := a.library.CoverArtPathByEntityID(r.Context(), id)
+ if err != nil {
+ if errors.Is(err, library.ErrNotFound) {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "cover art not found"})
+ return
+ }
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load cover art"})
+ return
+ }
+
+ file, err := os.Open(path)
+ if err != nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "cover art file not available"})
+ return
+ }
+ defer file.Close()
+
+ info, err := file.Stat()
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to stat cover art"})
+ return
+ }
+
+ w.Header().Set("Content-Type", detectImageContentType(path))
+ http.ServeContent(w, r, info.Name(), info.ModTime(), file)
+}
+
+func detectImageContentType(path string) string {
+ lower := strings.ToLower(path)
+ switch {
+ case strings.HasSuffix(lower, ".jpg"), strings.HasSuffix(lower, ".jpeg"):
+ return "image/jpeg"
+ case strings.HasSuffix(lower, ".png"):
+ return "image/png"
+ default:
+ return "application/octet-stream"
+ }
+}
+
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
diff --git a/internal/library/service.go b/internal/library/service.go
index 8a41521..9efdc6f 100644
--- a/internal/library/service.go
+++ b/internal/library/service.go
@@ -3,9 +3,12 @@ package library
import (
"context"
"database/sql"
+ "errors"
"fmt"
)
+var ErrNotFound = errors.New("not found")
+
type Artist struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -19,6 +22,7 @@ type Album struct {
Title string `json:"title"`
Year int `json:"year"`
TrackCount int `json:"trackCount"`
+ CoverArtID string `json:"coverArtId"`
}
type Track struct {
@@ -32,6 +36,7 @@ type Track struct {
DurationSecs int `json:"durationSeconds"`
FilePath string `json:"filePath"`
ContentType string `json:"contentType"`
+ CoverArtID string `json:"coverArtId"`
}
type HomePayload struct {
@@ -96,6 +101,7 @@ func (s *Service) RecentAlbums(ctx context.Context, limit int) ([]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) AS track_count
+ , COALESCE(al.cover_art_id, '')
FROM albums al
JOIN artists a ON a.id = al.artist_id
LEFT JOIN tracks t ON t.album_id = al.id
@@ -112,7 +118,7 @@ func (s *Service) RecentAlbums(ctx context.Context, limit int) ([]Album, error)
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); err != nil {
+ 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 album: %w", err)
}
albums = append(albums, album)
@@ -125,7 +131,7 @@ func (s *Service) Tracks(ctx context.Context, limit int) ([]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(t.duration_seconds, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '')
FROM tracks t
JOIN artists a ON a.id = t.artist_id
JOIN albums al ON al.id = t.album_id
@@ -152,6 +158,7 @@ func (s *Service) Tracks(ctx context.Context, limit int) ([]Track, error) {
&track.DurationSecs,
&track.FilePath,
&track.ContentType,
+ &track.CoverArtID,
); err != nil {
return nil, fmt.Errorf("scan track: %w", err)
}
@@ -167,7 +174,7 @@ func (s *Service) TrackByID(ctx context.Context, id string) (Track, error) {
err := s.db.QueryRowContext(
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(t.duration_seconds, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '')
FROM tracks t
JOIN artists a ON a.id = t.artist_id
JOIN albums al ON al.id = t.album_id
@@ -184,6 +191,7 @@ func (s *Service) TrackByID(ctx context.Context, id string) (Track, error) {
&track.DurationSecs,
&track.FilePath,
&track.ContentType,
+ &track.CoverArtID,
)
if err != nil {
return Track{}, fmt.Errorf("query track by id: %w", err)
@@ -191,3 +199,35 @@ func (s *Service) TrackByID(ctx context.Context, id string) (Track, error) {
return track, nil
}
+
+func (s *Service) CoverArtPathByEntityID(ctx context.Context, id string) (string, error) {
+ var path string
+
+ err := s.db.QueryRowContext(
+ ctx,
+ `SELECT cover_art_id FROM albums WHERE id = ? AND cover_art_id <> ''`,
+ id,
+ ).Scan(&path)
+ if err == nil {
+ return path, nil
+ }
+ if err != nil && !errors.Is(err, sql.ErrNoRows) {
+ return "", fmt.Errorf("query album cover art: %w", err)
+ }
+
+ err = s.db.QueryRowContext(
+ ctx,
+ `SELECT al.cover_art_id
+ FROM tracks t
+ JOIN albums al ON al.id = t.album_id
+ WHERE t.id = ? AND al.cover_art_id <> ''`,
+ id,
+ ).Scan(&path)
+ if err == nil {
+ return path, nil
+ }
+ if errors.Is(err, sql.ErrNoRows) {
+ return "", ErrNotFound
+ }
+ return "", fmt.Errorf("query track cover art: %w", err)
+}
diff --git a/internal/scanner/service.go b/internal/scanner/service.go
index df8ddce..1f4edbb 100644
--- a/internal/scanner/service.go
+++ b/internal/scanner/service.go
@@ -11,6 +11,7 @@ import (
"path/filepath"
"slices"
"strings"
+ "sync"
"time"
"github.com/dhowden/tag"
@@ -37,6 +38,18 @@ type Result struct {
type Service struct {
db *sql.DB
mediaRoot string
+ mu sync.RWMutex
+ status Status
+}
+
+type Status struct {
+ Scanning bool `json:"scanning"`
+ StartedAt time.Time `json:"startedAt,omitempty"`
+ FinishedAt time.Time `json:"finishedAt,omitempty"`
+ LastError string `json:"lastError,omitempty"`
+ Artists int `json:"artists"`
+ Albums int `json:"albums"`
+ Tracks int `json:"tracks"`
}
type scannedArtist struct {
@@ -50,6 +63,7 @@ type scannedAlbum struct {
Title string
Year int
Genre string
+ CoverArt string
}
type scannedTrack struct {
@@ -84,12 +98,23 @@ func (s *Service) HasMediaFiles() bool {
}
func (s *Service) Scan(ctx context.Context) (Result, error) {
+ if !s.tryMarkStarted() {
+ return Result{}, fmt.Errorf("scan already running")
+ }
+ return s.runScan(ctx)
+}
+
+func (s *Service) runScan(ctx context.Context) (Result, error) {
if s.mediaRoot == "" {
- return Result{}, fmt.Errorf("media root is empty")
+ err := fmt.Errorf("media root is empty")
+ s.markFailed(err)
+ return Result{}, err
}
if _, err := os.Stat(s.mediaRoot); err != nil {
- return Result{}, fmt.Errorf("media root unavailable: %w", err)
+ wrapped := fmt.Errorf("media root unavailable: %w", err)
+ s.markFailed(wrapped)
+ return Result{}, wrapped
}
artists := map[string]scannedArtist{}
@@ -115,25 +140,35 @@ func (s *Service) Scan(ctx context.Context) (Result, error) {
return nil
})
if err != nil {
- return Result{}, fmt.Errorf("walk media root: %w", err)
+ wrapped := fmt.Errorf("walk media root: %w", err)
+ s.markFailed(wrapped)
+ return Result{}, wrapped
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
- return Result{}, fmt.Errorf("begin scan transaction: %w", err)
+ wrapped := fmt.Errorf("begin scan transaction: %w", err)
+ s.markFailed(wrapped)
+ return Result{}, wrapped
}
if _, err := tx.ExecContext(ctx, `DELETE FROM tracks`); err != nil {
_ = tx.Rollback()
- return Result{}, fmt.Errorf("clear tracks: %w", err)
+ wrapped := fmt.Errorf("clear tracks: %w", err)
+ s.markFailed(wrapped)
+ return Result{}, wrapped
}
if _, err := tx.ExecContext(ctx, `DELETE FROM albums`); err != nil {
_ = tx.Rollback()
- return Result{}, fmt.Errorf("clear albums: %w", err)
+ wrapped := fmt.Errorf("clear albums: %w", err)
+ s.markFailed(wrapped)
+ return Result{}, wrapped
}
if _, err := tx.ExecContext(ctx, `DELETE FROM artists`); err != nil {
_ = tx.Rollback()
- return Result{}, fmt.Errorf("clear artists: %w", err)
+ wrapped := fmt.Errorf("clear artists: %w", err)
+ s.markFailed(wrapped)
+ return Result{}, wrapped
}
now := time.Now().UTC().Format(time.RFC3339)
@@ -157,7 +192,9 @@ func (s *Service) Scan(ctx context.Context) (Result, error) {
now,
); err != nil {
_ = tx.Rollback()
- return Result{}, fmt.Errorf("insert artist: %w", err)
+ wrapped := fmt.Errorf("insert artist: %w", err)
+ s.markFailed(wrapped)
+ return Result{}, wrapped
}
}
@@ -178,12 +215,14 @@ func (s *Service) Scan(ctx context.Context) (Result, error) {
album.Title,
album.Year,
album.Genre,
- "",
+ album.CoverArt,
now,
now,
); err != nil {
_ = tx.Rollback()
- return Result{}, fmt.Errorf("insert album: %w", err)
+ wrapped := fmt.Errorf("insert album: %w", err)
+ s.markFailed(wrapped)
+ return Result{}, wrapped
}
}
@@ -204,19 +243,44 @@ func (s *Service) Scan(ctx context.Context) (Result, error) {
now,
); err != nil {
_ = tx.Rollback()
- return Result{}, fmt.Errorf("insert track: %w", err)
+ wrapped := fmt.Errorf("insert track: %w", err)
+ s.markFailed(wrapped)
+ return Result{}, wrapped
}
}
if err := tx.Commit(); err != nil {
- return Result{}, fmt.Errorf("commit scan transaction: %w", err)
+ wrapped := fmt.Errorf("commit scan transaction: %w", err)
+ s.markFailed(wrapped)
+ return Result{}, wrapped
}
- return Result{
+ result := Result{
Artists: len(artists),
Albums: len(albums),
Tracks: len(tracks),
- }, nil
+ }
+
+ s.markFinished(result)
+ return result, nil
+}
+
+func (s *Service) ScanAsync(ctx context.Context) bool {
+ if !s.tryMarkStarted() {
+ return false
+ }
+
+ go func() {
+ _, _ = s.runScan(ctx)
+ }()
+
+ return true
+}
+
+func (s *Service) Status() Status {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.status
}
type scannedItem struct {
@@ -263,6 +327,7 @@ func (s *Service) scanFile(path string) (scannedItem, error) {
artistID := hashID("artist", artistName)
albumID := hashID("album", artistName, albumTitle, fmt.Sprintf("%d", year))
trackID := hashID("track", path)
+ coverArt := findCoverArt(filepath.Dir(path))
return scannedItem{
artist: scannedArtist{
@@ -275,6 +340,7 @@ func (s *Service) scanFile(path string) (scannedItem, error) {
Title: albumTitle,
Year: year,
Genre: genre,
+ CoverArt: coverArt,
},
track: scannedTrack{
ID: trackID,
@@ -327,3 +393,58 @@ func hashID(parts ...string) string {
sum := sha1.Sum([]byte(strings.Join(parts, "::")))
return hex.EncodeToString(sum[:])
}
+
+func findCoverArt(dir string) string {
+ candidates := []string{
+ "cover.jpg",
+ "cover.jpeg",
+ "cover.png",
+ "folder.jpg",
+ "folder.jpeg",
+ "folder.png",
+ "front.jpg",
+ "front.jpeg",
+ "front.png",
+ }
+
+ for _, candidate := range candidates {
+ path := filepath.Join(dir, candidate)
+ if _, err := os.Stat(path); err == nil {
+ return path
+ }
+ }
+
+ return ""
+}
+
+func (s *Service) tryMarkStarted() bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.status.Scanning {
+ return false
+ }
+ s.status.Scanning = true
+ s.status.StartedAt = time.Now().UTC()
+ s.status.FinishedAt = time.Time{}
+ s.status.LastError = ""
+ return true
+}
+
+func (s *Service) markFinished(result Result) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.status.Scanning = false
+ s.status.FinishedAt = time.Now().UTC()
+ s.status.LastError = ""
+ s.status.Artists = result.Artists
+ s.status.Albums = result.Albums
+ s.status.Tracks = result.Tracks
+}
+
+func (s *Service) markFailed(err error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.status.Scanning = false
+ s.status.FinishedAt = time.Now().UTC()
+ s.status.LastError = err.Error()
+}
diff --git a/internal/subsonic/service.go b/internal/subsonic/service.go
index 4d5a0b2..319a937 100644
--- a/internal/subsonic/service.go
+++ b/internal/subsonic/service.go
@@ -1,6 +1,11 @@
package subsonic
-import "github.com/benya/temporserv/internal/library"
+import (
+ "time"
+
+ "github.com/benya/temporserv/internal/library"
+ "github.com/benya/temporserv/internal/scanner"
+)
type Envelope struct {
SubsonicResponse Response `json:"subsonic-response"`
@@ -14,6 +19,8 @@ type Response struct {
OpenAPI bool `json:"openSubsonic"`
Artists []ArtistRef `json:"artists,omitempty"`
RandomSong []SongRef `json:"randomSongs,omitempty"`
+ ScanStatus *ScanStatus `json:"scanStatus,omitempty"`
+ Error *ErrorRef `json:"error,omitempty"`
}
type ArtistRef struct {
@@ -28,6 +35,20 @@ type SongRef struct {
Artist string `json:"artist"`
}
+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"`
+}
+
+type ErrorRef struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+}
+
func PingResponse() Envelope {
return Envelope{
SubsonicResponse: Response{
@@ -63,3 +84,33 @@ func RandomSongsResponse(tracks []library.Track) Envelope {
}
return response
}
+
+func ScanStatusResponse(status scanner.Status) Envelope {
+ response := PingResponse()
+ response.SubsonicResponse.ScanStatus = &ScanStatus{
+ Scanning: status.Scanning,
+ Count: status.Tracks,
+ FolderCount: status.Albums,
+ LastError: status.LastError,
+ StartedAt: formatTime(status.StartedAt),
+ FinishedAt: formatTime(status.FinishedAt),
+ }
+ return response
+}
+
+func ErrorResponse(code int, message string) Envelope {
+ response := PingResponse()
+ response.SubsonicResponse.Status = "failed"
+ response.SubsonicResponse.Error = &ErrorRef{
+ Code: code,
+ Message: message,
+ }
+ return response
+}
+
+func formatTime(value time.Time) string {
+ if value.IsZero() {
+ return ""
+ }
+ return value.Format(time.RFC3339)
+}