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