feat: add scan status and cover art endpoints

Track scanner status for the web API and Subsonic-compatible scan endpoints, add authenticated cover art serving, and wire album artwork into the web UI. Keep Subsonic auth limited to legacy password mode for now so behavior stays honest with the current bcrypt-based user storage.
This commit is contained in:
2026-04-02 22:37:10 +03:00
parent 46c2c3fb28
commit f8880aa9a4
9 changed files with 394 additions and 23 deletions

1
.gitignore vendored
View File

@@ -4,6 +4,7 @@ build/
.vite/ .vite/
*.tsbuildinfo *.tsbuildinfo
data/ data/
media/
server.exe server.exe
.DS_Store .DS_Store
.env .env

View File

@@ -14,6 +14,7 @@ export type HomePayload = {
title: string title: string
year: number year: number
trackCount: number trackCount: number
coverArtId: string
}> }>
artists: Array<{ artists: Array<{
id: string id: string
@@ -67,3 +68,8 @@ export async function fetchHome() {
export async function fetchTracks() { export async function fetchTracks() {
return request<{ items: Track[] }>('/api/tracks') 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)}` : ''}`
}

View File

@@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { fetchHome } from '@/lib/api' import { coverArtUrl, fetchHome } from '@/lib/api'
import { SectionTitle } from '@/components/section-title' import { SectionTitle } from '@/components/section-title'
export function HomePage() { export function HomePage() {
@@ -28,7 +28,15 @@ export function HomePage() {
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-3">
{home?.recentAlbums.map((album) => ( {home?.recentAlbums.map((album) => (
<article key={album.id} className="rounded-[24px] border border-line bg-panel p-4"> <article key={album.id} className="rounded-[24px] border border-line bg-panel p-4">
<div className="aspect-square rounded-[20px] bg-[linear-gradient(145deg,#1f2f45,#152236)]" /> {album.coverArtId ? (
<img
alt={album.title}
className="aspect-square w-full rounded-[20px] object-cover"
src={coverArtUrl(album.id)}
/>
) : (
<div className="aspect-square rounded-[20px] bg-[linear-gradient(145deg,#1f2f45,#152236)]" />
)}
<div className="mt-4 text-lg font-semibold">{album.title}</div> <div className="mt-4 text-lg font-semibold">{album.title}</div>
<div className="text-sm text-slate-400">{album.artistName}</div> <div className="text-sm text-slate-400">{album.artistName}</div>
<div className="mt-2 text-xs uppercase tracking-[0.22em] text-slate-500"> <div className="mt-2 text-xs uppercase tracking-[0.22em] text-slate-500">
@@ -57,4 +65,3 @@ export function HomePage() {
</div> </div>
) )
} }

View File

@@ -107,6 +107,40 @@ func (s *Service) CurrentUserByToken(ctx context.Context, token string) (User, e
return user, nil 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) { func (s *Service) findUserByUsername(ctx context.Context, username string) (User, string, error) {
var user User var user User
var passwordHash string var passwordHash string

View File

@@ -79,3 +79,34 @@ func currentUserFromContext(r *http.Request) auth.User {
} }
return 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))
})
}

View File

@@ -52,9 +52,11 @@ func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service
private.Get("/me", application.me) private.Get("/me", application.me)
private.Get("/home", application.home) private.Get("/home", application.home)
private.Get("/tracks", application.tracks) private.Get("/tracks", application.tracks)
private.Get("/admin/scan-status", application.scanStatus)
private.Post("/admin/scan", application.scanLibrary) private.Post("/admin/scan", application.scanLibrary)
}) })
api.Get("/cover-art/{id}", application.coverArt)
api.Get("/stream/{id}", application.streamTrack) 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) { rest.Get("/getLicense.view", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, subsonic.PingResponse()) writeJSON(w, http.StatusOK, subsonic.PingResponse())
}) })
rest.Get("/getArtists.view", application.subsonicArtists) rest.Group(func(authed chi.Router) {
rest.Get("/getRandomSongs.view", application.subsonicRandomSongs) 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")) 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) 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) { func (a app) streamTrack(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token") token := r.URL.Query().Get("token")
if 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) 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) { func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) w.WriteHeader(status)

View File

@@ -3,9 +3,12 @@ package library
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
) )
var ErrNotFound = errors.New("not found")
type Artist struct { type Artist struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -19,6 +22,7 @@ type Album struct {
Title string `json:"title"` Title string `json:"title"`
Year int `json:"year"` Year int `json:"year"`
TrackCount int `json:"trackCount"` TrackCount int `json:"trackCount"`
CoverArtID string `json:"coverArtId"`
} }
type Track struct { type Track struct {
@@ -32,6 +36,7 @@ type Track struct {
DurationSecs int `json:"durationSeconds"` DurationSecs int `json:"durationSeconds"`
FilePath string `json:"filePath"` FilePath string `json:"filePath"`
ContentType string `json:"contentType"` ContentType string `json:"contentType"`
CoverArtID string `json:"coverArtId"`
} }
type HomePayload struct { type HomePayload struct {
@@ -96,6 +101,7 @@ func (s *Service) RecentAlbums(ctx context.Context, limit int) ([]Album, error)
rows, err := s.db.QueryContext( rows, err := s.db.QueryContext(
ctx, ctx,
`SELECT al.id, al.artist_id, a.name, al.title, COALESCE(al.year, 0), COUNT(t.id) AS track_count `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 FROM albums al
JOIN artists a ON a.id = al.artist_id JOIN artists a ON a.id = al.artist_id
LEFT JOIN tracks t ON t.album_id = al.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 var albums []Album
for rows.Next() { for rows.Next() {
var album Album 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) return nil, fmt.Errorf("scan album: %w", err)
} }
albums = append(albums, album) albums = append(albums, album)
@@ -125,7 +131,7 @@ func (s *Service) Tracks(ctx context.Context, limit int) ([]Track, error) {
rows, err := s.db.QueryContext( rows, err := s.db.QueryContext(
ctx, ctx,
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(t.track_number, 0), `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 FROM tracks t
JOIN artists a ON a.id = t.artist_id JOIN artists a ON a.id = t.artist_id
JOIN albums al ON al.id = t.album_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.DurationSecs,
&track.FilePath, &track.FilePath,
&track.ContentType, &track.ContentType,
&track.CoverArtID,
); err != nil { ); err != nil {
return nil, fmt.Errorf("scan track: %w", err) 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( err := s.db.QueryRowContext(
ctx, ctx,
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(t.track_number, 0), `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 FROM tracks t
JOIN artists a ON a.id = t.artist_id JOIN artists a ON a.id = t.artist_id
JOIN albums al ON al.id = t.album_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.DurationSecs,
&track.FilePath, &track.FilePath,
&track.ContentType, &track.ContentType,
&track.CoverArtID,
) )
if err != nil { if err != nil {
return Track{}, fmt.Errorf("query track by id: %w", err) 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 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)
}

View File

@@ -11,6 +11,7 @@ import (
"path/filepath" "path/filepath"
"slices" "slices"
"strings" "strings"
"sync"
"time" "time"
"github.com/dhowden/tag" "github.com/dhowden/tag"
@@ -37,6 +38,18 @@ type Result struct {
type Service struct { type Service struct {
db *sql.DB db *sql.DB
mediaRoot string 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 { type scannedArtist struct {
@@ -50,6 +63,7 @@ type scannedAlbum struct {
Title string Title string
Year int Year int
Genre string Genre string
CoverArt string
} }
type scannedTrack struct { type scannedTrack struct {
@@ -84,12 +98,23 @@ func (s *Service) HasMediaFiles() bool {
} }
func (s *Service) Scan(ctx context.Context) (Result, error) { 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 == "" { 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 { 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{} artists := map[string]scannedArtist{}
@@ -115,25 +140,35 @@ func (s *Service) Scan(ctx context.Context) (Result, error) {
return nil return nil
}) })
if err != 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) tx, err := s.db.BeginTx(ctx, nil)
if err != 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 { if _, err := tx.ExecContext(ctx, `DELETE FROM tracks`); err != nil {
_ = tx.Rollback() _ = 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 { if _, err := tx.ExecContext(ctx, `DELETE FROM albums`); err != nil {
_ = tx.Rollback() _ = 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 { if _, err := tx.ExecContext(ctx, `DELETE FROM artists`); err != nil {
_ = tx.Rollback() _ = 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) now := time.Now().UTC().Format(time.RFC3339)
@@ -157,7 +192,9 @@ func (s *Service) Scan(ctx context.Context) (Result, error) {
now, now,
); err != nil { ); err != nil {
_ = tx.Rollback() _ = 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.Title,
album.Year, album.Year,
album.Genre, album.Genre,
"", album.CoverArt,
now, now,
now, now,
); err != nil { ); err != nil {
_ = tx.Rollback() _ = 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, now,
); err != nil { ); err != nil {
_ = tx.Rollback() _ = 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 { 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), Artists: len(artists),
Albums: len(albums), Albums: len(albums),
Tracks: len(tracks), 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 { type scannedItem struct {
@@ -263,6 +327,7 @@ func (s *Service) scanFile(path string) (scannedItem, error) {
artistID := hashID("artist", artistName) artistID := hashID("artist", artistName)
albumID := hashID("album", artistName, albumTitle, fmt.Sprintf("%d", year)) albumID := hashID("album", artistName, albumTitle, fmt.Sprintf("%d", year))
trackID := hashID("track", path) trackID := hashID("track", path)
coverArt := findCoverArt(filepath.Dir(path))
return scannedItem{ return scannedItem{
artist: scannedArtist{ artist: scannedArtist{
@@ -275,6 +340,7 @@ func (s *Service) scanFile(path string) (scannedItem, error) {
Title: albumTitle, Title: albumTitle,
Year: year, Year: year,
Genre: genre, Genre: genre,
CoverArt: coverArt,
}, },
track: scannedTrack{ track: scannedTrack{
ID: trackID, ID: trackID,
@@ -327,3 +393,58 @@ func hashID(parts ...string) string {
sum := sha1.Sum([]byte(strings.Join(parts, "::"))) sum := sha1.Sum([]byte(strings.Join(parts, "::")))
return hex.EncodeToString(sum[:]) 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()
}

View File

@@ -1,6 +1,11 @@
package subsonic 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 { type Envelope struct {
SubsonicResponse Response `json:"subsonic-response"` SubsonicResponse Response `json:"subsonic-response"`
@@ -14,6 +19,8 @@ type Response struct {
OpenAPI bool `json:"openSubsonic"` OpenAPI bool `json:"openSubsonic"`
Artists []ArtistRef `json:"artists,omitempty"` Artists []ArtistRef `json:"artists,omitempty"`
RandomSong []SongRef `json:"randomSongs,omitempty"` RandomSong []SongRef `json:"randomSongs,omitempty"`
ScanStatus *ScanStatus `json:"scanStatus,omitempty"`
Error *ErrorRef `json:"error,omitempty"`
} }
type ArtistRef struct { type ArtistRef struct {
@@ -28,6 +35,20 @@ type SongRef struct {
Artist string `json:"artist"` 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 { func PingResponse() Envelope {
return Envelope{ return Envelope{
SubsonicResponse: Response{ SubsonicResponse: Response{
@@ -63,3 +84,33 @@ func RandomSongsResponse(tracks []library.Track) Envelope {
} }
return response 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)
}