Files
TermorServer/internal/httpapi/router.go
benya f8880aa9a4 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.
2026-04-02 22:37:10 +03:00

281 lines
8.3 KiB
Go

package httpapi
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
"os"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/benya/temporserv/internal/auth"
"github.com/benya/temporserv/internal/config"
"github.com/benya/temporserv/internal/library"
"github.com/benya/temporserv/internal/scanner"
"github.com/benya/temporserv/internal/subsonic"
)
type app struct {
auth *auth.Service
library *library.Service
scanner *scanner.Service
}
func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service) http.Handler {
application := app{
auth: auth.NewService(database),
library: library.NewService(database),
scanner: scanService,
}
r := chi.NewRouter()
r.Use(requestLogger)
r.Use(recoverer)
r.Use(cors(cfg.CORSOrigins))
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"time": time.Now().UTC(),
"env": cfg.AppEnv,
})
})
r.Route("/api", func(api chi.Router) {
api.Post("/auth/login", application.login)
api.Group(func(private chi.Router) {
private.Use(application.requireAuth)
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)
})
r.Route("/rest", func(rest chi.Router) {
rest.Get("/ping.view", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, subsonic.PingResponse())
})
rest.Get("/getLicense.view", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, subsonic.PingResponse())
})
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"))
r.Handle("/*", spaFallback(fs))
return r
}
func (a app) login(w http.ResponseWriter, r *http.Request) {
var payload struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
session, err := a.auth.Login(r.Context(), payload.Username, payload.Password)
if err != nil {
if errors.Is(err, auth.ErrInvalidCredentials) {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
return
}
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "login failed"})
return
}
writeJSON(w, http.StatusOK, session)
}
func (a app) me(w http.ResponseWriter, r *http.Request) {
user := currentUserFromContext(r)
writeJSON(w, http.StatusOK, user)
}
func (a app) home(w http.ResponseWriter, r *http.Request) {
home, err := a.library.Home(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load home"})
return
}
writeJSON(w, http.StatusOK, home)
}
func (a app) tracks(w http.ResponseWriter, r *http.Request) {
items, err := a.library.Tracks(r.Context(), 200)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load tracks"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (a app) subsonicArtists(w http.ResponseWriter, r *http.Request) {
artists, err := a.library.Artists(r.Context(), 1000)
if err != nil {
writeJSON(w, http.StatusInternalServerError, subsonic.PingResponse())
return
}
writeJSON(w, http.StatusOK, subsonic.ArtistsResponse(artists))
}
func (a app) subsonicRandomSongs(w http.ResponseWriter, r *http.Request) {
tracks, err := a.library.Tracks(r.Context(), 20)
if err != nil {
writeJSON(w, http.StatusInternalServerError, subsonic.PingResponse())
return
}
writeJSON(w, http.StatusOK, subsonic.RandomSongsResponse(tracks))
}
func (a app) scanLibrary(w http.ResponseWriter, r *http.Request) {
result, err := a.scanner.Scan(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
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 == "" {
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
}
track, err := a.library.TrackByID(r.Context(), chi.URLParam(r, "id"))
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "track not found"})
return
}
file, err := os.Open(track.FilePath)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "audio 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 file"})
return
}
w.Header().Set("Content-Type", track.ContentType)
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)
_ = json.NewEncoder(w).Encode(payload)
}
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") {
http.NotFound(w, r)
return
}
next.ServeHTTP(w, r)
}
}