Files
TermorServer/internal/httpapi/router.go
benya 2e7283baad feat: redesign web interface to match aonsoku layout
Replace the early prototype UI with a darker Aonsoku-inspired shell featuring a compact top bar, library sidebar, command palette, settings overlay, dense track list, artists table, albums grid, and a bottom player bar. Add a supporting albums browse endpoint so the frontend can render the same navigation shape without faking data.
2026-04-02 22:53:13 +03:00

413 lines
13 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("/artists", application.artists)
private.Get("/artists/{id}", application.artistByID)
private.Get("/albums", application.albums)
private.Get("/albums/{id}", application.albumByID)
private.Get("/tracks", application.tracks)
private.Get("/tracks/{id}", application.trackByID)
private.Get("/search", application.search)
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("/getArtist.view", application.subsonicArtistByID)
authed.Get("/getAlbum.view", application.subsonicAlbumByID)
authed.Get("/getSong.view", application.subsonicSongByID)
authed.Get("/getRandomSongs.view", application.subsonicRandomSongs)
authed.Get("/getScanStatus.view", application.subsonicScanStatus)
authed.Get("/startScan.view", application.subsonicStartScan)
authed.Get("/getCoverArt.view", application.subsonicCoverArt)
authed.Get("/stream.view", application.subsonicStream)
})
})
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) artists(w http.ResponseWriter, r *http.Request) {
items, err := a.library.Artists(r.Context(), 500)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load artists"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (a app) artistByID(w http.ResponseWriter, r *http.Request) {
item, err := a.library.ArtistByID(r.Context(), chi.URLParam(r, "id"))
if err != nil {
if errors.Is(err, library.ErrNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "artist not found"})
return
}
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load artist"})
return
}
writeJSON(w, http.StatusOK, item)
}
func (a app) albums(w http.ResponseWriter, r *http.Request) {
items, err := a.library.Albums(r.Context(), 1000)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load albums"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (a app) albumByID(w http.ResponseWriter, r *http.Request) {
item, err := a.library.AlbumByID(r.Context(), chi.URLParam(r, "id"))
if err != nil {
if errors.Is(err, library.ErrNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "album not found"})
return
}
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load album"})
return
}
writeJSON(w, http.StatusOK, item)
}
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) trackByID(w http.ResponseWriter, r *http.Request) {
item, err := a.library.TrackByID(r.Context(), chi.URLParam(r, "id"))
if err != nil {
if errors.Is(err, library.ErrNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "track not found"})
return
}
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load track"})
return
}
writeJSON(w, http.StatusOK, item)
}
func (a app) search(w http.ResponseWriter, r *http.Request) {
query := strings.TrimSpace(r.URL.Query().Get("q"))
if query == "" {
writeJSON(w, http.StatusOK, library.SearchResults{})
return
}
results, err := a.library.Search(r.Context(), query, 20)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "search failed"})
return
}
writeJSON(w, http.StatusOK, results)
}
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) subsonicArtistByID(w http.ResponseWriter, r *http.Request) {
item, err := a.library.ArtistByID(r.Context(), r.URL.Query().Get("id"))
if err != nil {
if errors.Is(err, library.ErrNotFound) {
writeJSON(w, http.StatusNotFound, subsonic.ErrorResponse(70, "artist not found"))
return
}
writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to load artist"))
return
}
writeJSON(w, http.StatusOK, subsonic.ArtistResponse(item))
}
func (a app) subsonicAlbumByID(w http.ResponseWriter, r *http.Request) {
item, err := a.library.AlbumByID(r.Context(), r.URL.Query().Get("id"))
if err != nil {
if errors.Is(err, library.ErrNotFound) {
writeJSON(w, http.StatusNotFound, subsonic.ErrorResponse(70, "album not found"))
return
}
writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to load album"))
return
}
writeJSON(w, http.StatusOK, subsonic.AlbumResponse(item))
}
func (a app) subsonicSongByID(w http.ResponseWriter, r *http.Request) {
item, err := a.library.TrackByID(r.Context(), r.URL.Query().Get("id"))
if err != nil {
if errors.Is(err, library.ErrNotFound) {
writeJSON(w, http.StatusNotFound, subsonic.ErrorResponse(70, "song not found"))
return
}
writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to load song"))
return
}
writeJSON(w, http.StatusOK, subsonic.SongResponse(item))
}
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
}
a.serveTrackByID(w, r, chi.URLParam(r, "id"))
}
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) subsonicStream(w http.ResponseWriter, r *http.Request) {
a.serveTrackByID(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 (a app) serveTrackByID(w http.ResponseWriter, r *http.Request, id string) {
track, err := a.library.TrackByID(r.Context(), id)
if err != nil {
if errors.Is(err, library.ErrNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "track not found"})
return
}
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load track"})
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 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)
}
}