package api import ( "bytes" "encoding/json" "fmt" "net/http" "os/exec" "strconv" "strings" "sheepflix/internal/library" "sheepflix/internal/probe" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" ) const authToken = "i-am-pepper" func NewRouter(lib *library.Library) http.Handler { r := chi.NewRouter() r.Use(middleware.Logger) r.Use(middleware.Recoverer) r.Use(authMiddleware) r.Get("/api/health", healthHandler) r.Get("/api/library", listHandler(lib)) r.Get("/api/library/{id}", detailHandler(lib)) r.Get("/api/search", searchHandler(lib)) r.Get("/api/rescan", rescanHandler(lib)) r.Get("/api/stream/{id}", streamHandler(lib)) r.Get("/api/subtitles/{id}/{index}", subtitleHandler(lib)) return r } func authMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Accept token from Authorization header or ?token= query param token := r.Header.Get("Authorization") if token == "" { token = r.URL.Query().Get("token") } else { // Strip "Bearer " prefix if present token = strings.TrimPrefix(token, "Bearer ") } if token != authToken { http.Error(w, "unauthorized", http.StatusUnauthorized) return } w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") if r.Method == "OPTIONS" { w.WriteHeader(http.StatusNoContent) return } next.ServeHTTP(w, r) }) } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(v) } func healthHandler(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func listHandler(lib *library.Library) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { sortBy := r.URL.Query().Get("sort") if sortBy == "" { sortBy = "name" } offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) if limit <= 0 || limit > 500 { limit = 50 } items, total := lib.List(sortBy, offset, limit) writeJSON(w, http.StatusOK, map[string]any{ "items": items, "total": total, }) } } func detailHandler(lib *library.Library) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") item, fullPath, ok := lib.Get(id) if !ok { writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) return } probeResult, err := probe.Run(fullPath) if err != nil { writeJSON(w, http.StatusOK, map[string]any{ "item": item, "probe": nil, "probe_error": err.Error(), }) return } for i := range probeResult.Subtitles { probeResult.Subtitles[i].URL = fmt.Sprintf("/api/subtitles/%s/%d", id, probeResult.Subtitles[i].Index) } writeJSON(w, http.StatusOK, map[string]any{ "item": item, "probe": probeResult, }) } } func searchHandler(lib *library.Library) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { q := r.URL.Query().Get("q") if q == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing q parameter"}) return } results := lib.Search(q) writeJSON(w, http.StatusOK, map[string]any{ "items": results, "total": len(results), }) } } func streamHandler(lib *library.Library) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") _, fullPath, ok := lib.Get(id) if !ok { http.Error(w, "not found", http.StatusNotFound) return } http.ServeFile(w, r, fullPath) } } func subtitleHandler(lib *library.Library) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") index, err := strconv.Atoi(chi.URLParam(r, "index")) if err != nil || index < 0 { http.Error(w, "invalid subtitle index", http.StatusBadRequest) return } _, fullPath, ok := lib.Get(id) if !ok { http.Error(w, "not found", http.StatusNotFound) return } metadata, err := probe.Run(fullPath) if err != nil { http.Error(w, "unable to inspect media", http.StatusInternalServerError) return } var subtitle *probe.SubTrack for i := range metadata.Subtitles { if metadata.Subtitles[i].Index == index { subtitle = &metadata.Subtitles[i] break } } if subtitle == nil { http.Error(w, "subtitle track not found", http.StatusNotFound) return } if !isTextSubtitleCodec(subtitle.Codec) { http.Error(w, fmt.Sprintf("subtitle codec %q requires OCR or burn-in", subtitle.Codec), http.StatusUnsupportedMediaType) return } cmd := exec.CommandContext(r.Context(), "ffmpeg", "-v", "error", "-i", fullPath, "-map", fmt.Sprintf("0:%d", index), "-c:s", "webvtt", "-f", "webvtt", "pipe:1", ) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { http.Error(w, fmt.Sprintf("subtitle conversion failed: %s", strings.TrimSpace(stderr.String())), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/vtt; charset=utf-8") w.Header().Set("Cache-Control", "public, max-age=86400") _, _ = w.Write(stdout.Bytes()) } } func isTextSubtitleCodec(codec string) bool { switch strings.ToLower(codec) { case "ass", "ssa", "subrip", "srt", "text", "webvtt", "mov_text": return true default: return false } } func rescanHandler(lib *library.Library) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { go lib.Scan() writeJSON(w, http.StatusOK, map[string]string{"status": "rescan started"}) } }