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

View File

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

View File

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