Files
TermorServer/internal/httpapi/router.go
benya 46c2c3fb28 feat: import library from media root and stream tracks
Add a filesystem scanner that ingests supported audio files from MEDIA_ROOT into SQLite using embedded tags with filename fallbacks. Wire startup scanning, manual rescan, and authenticated audio streaming into the backend, then connect the web player to the real stream endpoint.
2026-04-02 22:29:04 +03:00

201 lines
5.6 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.Post("/admin/scan", application.scanLibrary)
})
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.Get("/getArtists.view", application.subsonicArtists)
rest.Get("/getRandomSongs.view", application.subsonicRandomSongs)
})
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) 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 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)
}
}