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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,6 +4,7 @@ build/
|
||||
.vite/
|
||||
*.tsbuildinfo
|
||||
data/
|
||||
media/
|
||||
server.exe
|
||||
.DS_Store
|
||||
.env
|
||||
|
||||
@@ -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)}` : ''}`
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{home?.recentAlbums.map((album) => (
|
||||
<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="text-sm text-slate-400">{album.artistName}</div>
|
||||
<div className="mt-2 text-xs uppercase tracking-[0.22em] text-slate-500">
|
||||
@@ -57,4 +65,3 @@ export function HomePage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user