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.
201 lines
5.6 KiB
Go
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)
|
|
}
|
|
}
|