commit 6625e5927e1fc73e1b3b4ba106027174cfef0f66 Author: Olive Vaughn Date: Wed Aug 26 00:08:01 2026 -0400 Initial commit: sheepflix media streaming API Go API for browsing/searching media files with ffprobe metadata, token auth, and go-transcode integration for HLS streaming. Co-Authored-By: Claude Opus 4.6 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..77358ec --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +## Stage 1: Build sheepflix-api +FROM golang:1.23-alpine AS build-api +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY cmd/ cmd/ +COPY internal/ internal/ +RUN CGO_ENABLED=0 go build -o /sheepflix-api ./cmd/sheepflix + +## Stage 2: Runtime +FROM alpine:3.20 + +RUN apk add --no-cache \ + ffmpeg \ + libva \ + libva-intel-driver \ + intel-media-driver \ + mesa-va-gallium \ + bash + +COPY --from=build-api /sheepflix-api /usr/local/bin/sheepflix-api + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 8091 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/cmd/sheepflix/main.go b/cmd/sheepflix/main.go new file mode 100644 index 0000000..7c996a4 --- /dev/null +++ b/cmd/sheepflix/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "log" + "net/http" + "os" + "time" + + "sheepflix/internal/api" + "sheepflix/internal/library" +) + +func main() { + mediaDir := os.Getenv("MEDIA_DIR") + if mediaDir == "" { + mediaDir = "/media" + } + + listenAddr := os.Getenv("LISTEN_ADDR") + if listenAddr == "" { + listenAddr = ":8091" + } + + lib := library.New(mediaDir) + + log.Printf("scanning media directory: %s", mediaDir) + if err := lib.Scan(); err != nil { + log.Fatalf("initial scan failed: %v", err) + } + + lib.StartPeriodicScan(30 * time.Minute) + + router := api.NewRouter(lib) + + log.Printf("sheepflix-api listening on %s", listenAddr) + if err := http.ListenAndServe(listenAddr, router); err != nil { + log.Fatalf("server error: %v", err) + } +} diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..c53f771 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,33 @@ +# go-transcode configuration for VAAPI hardware encoding +# Copy this to go-transcode/config.yaml and adjust as needed + +vod: + media-dir: /media + transcode-dir: /tmp/go-transcode + # Cache transcoded segments + cache: true + +profiles: + 480p: + height: 480 + videoBitrate: 1500k + audioBitrate: 128k + hwaccel: vaapi + vaapi-device: /dev/dri/renderD128 + codec: h264_vaapi + + 720p: + height: 720 + videoBitrate: 3000k + audioBitrate: 192k + hwaccel: vaapi + vaapi-device: /dev/dri/renderD128 + codec: h264_vaapi + + 1080p: + height: 1080 + videoBitrate: 6000k + audioBitrate: 256k + hwaccel: vaapi + vaapi-device: /dev/dri/renderD128 + codec: h264_vaapi diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..04d82d1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +services: + sheepflix: + build: . + devices: + - /dev/dri:/dev/dri + volumes: + - /mnt/user/media:/media:ro + ports: + - "8091:8091" + environment: + - MEDIA_DIR=/media + - LISTEN_ADDR=:8091 + restart: unless-stopped diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..bc76136 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,12 @@ +#!/bin/bash +set -e + +# Start go-transcode in background if binary exists +if [ -x /usr/local/bin/go-transcode ]; then + echo "Starting go-transcode..." + /usr/local/bin/go-transcode & +fi + +# Start sheepflix-api in foreground +echo "Starting sheepflix-api..." +exec /usr/local/bin/sheepflix-api diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d72982d --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module sheepflix + +go 1.23.3 + +require github.com/go-chi/chi/v5 v5.3.2 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5a3b06d --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= +github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..cd922fa --- /dev/null +++ b/internal/api/api.go @@ -0,0 +1,137 @@ +package api + +import ( + "encoding/json" + "net/http" + "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)) + + 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 + } + + 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 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"}) + } +} diff --git a/internal/library/library.go b/internal/library/library.go new file mode 100644 index 0000000..c27fcc9 --- /dev/null +++ b/internal/library/library.go @@ -0,0 +1,150 @@ +package library + +import ( + "crypto/sha256" + "fmt" + "log" + "sort" + "strings" + "sync" + "time" + + "sheepflix/internal/scanner" +) + +type MediaItem struct { + ID string `json:"id"` + Name string `json:"name"` + RelPath string `json:"rel_path"` + Size int64 `json:"size"` + ModTime time.Time `json:"mod_time"` + StreamURL string `json:"stream_url"` + fullPath string +} + +type Library struct { + mu sync.RWMutex + mediaDir string + items []MediaItem + byID map[string]*MediaItem +} + +// New creates a new library that scans the given media directory. +func New(mediaDir string) *Library { + return &Library{ + mediaDir: mediaDir, + byID: make(map[string]*MediaItem), + } +} + +// stableID returns a deterministic ID from a relative path. +func stableID(relPath string) string { + h := sha256.Sum256([]byte(relPath)) + return fmt.Sprintf("%x", h[:8]) +} + +// Scan performs a full rescan of the media directory. +func (l *Library) Scan() error { + files, err := scanner.Scan(l.mediaDir) + if err != nil { + return fmt.Errorf("scan failed: %w", err) + } + + items := make([]MediaItem, 0, len(files)) + byID := make(map[string]*MediaItem, len(files)) + + for _, f := range files { + id := stableID(f.RelPath) + item := MediaItem{ + ID: id, + Name: f.Name, + RelPath: f.RelPath, + Size: f.Size, + ModTime: f.ModTime, + StreamURL: "/stream/vod/" + f.RelPath + "/index.m3u8", + fullPath: f.Path, + } + items = append(items, item) + byID[id] = &items[len(items)-1] + } + + l.mu.Lock() + l.items = items + l.byID = byID + l.mu.Unlock() + + log.Printf("library: indexed %d media files", len(items)) + return nil +} + +// StartPeriodicScan runs a rescan every interval. +func (l *Library) StartPeriodicScan(interval time.Duration) { + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for range ticker.C { + if err := l.Scan(); err != nil { + log.Printf("periodic scan error: %v", err) + } + } + }() +} + +// List returns all items sorted by the given field. +func (l *Library) List(sortBy string, offset, limit int) ([]MediaItem, int) { + l.mu.RLock() + sorted := make([]MediaItem, len(l.items)) + copy(sorted, l.items) + l.mu.RUnlock() + + switch sortBy { + case "date": + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].ModTime.After(sorted[j].ModTime) + }) + case "size": + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Size > sorted[j].Size + }) + default: // "name" + sort.Slice(sorted, func(i, j int) bool { + return strings.ToLower(sorted[i].Name) < strings.ToLower(sorted[j].Name) + }) + } + + total := len(sorted) + if offset >= total { + return nil, total + } + end := offset + limit + if end > total { + end = total + } + return sorted[offset:end], total +} + +// Get returns a single item by ID and its full filesystem path. +func (l *Library) Get(id string) (*MediaItem, string, bool) { + l.mu.RLock() + defer l.mu.RUnlock() + item, ok := l.byID[id] + if !ok { + return nil, "", false + } + return item, item.fullPath, true +} + +// Search returns items whose name contains the query (case-insensitive). +func (l *Library) Search(query string) []MediaItem { + q := strings.ToLower(query) + l.mu.RLock() + defer l.mu.RUnlock() + + var results []MediaItem + for _, item := range l.items { + if strings.Contains(strings.ToLower(item.Name), q) { + results = append(results, item) + } + } + return results +} diff --git a/internal/probe/probe.go b/internal/probe/probe.go new file mode 100644 index 0000000..4d9956b --- /dev/null +++ b/internal/probe/probe.go @@ -0,0 +1,109 @@ +package probe + +import ( + "encoding/json" + "fmt" + "os/exec" + "strconv" +) + +type ProbeResult struct { + Duration float64 `json:"duration"` + Width int `json:"width"` + Height int `json:"height"` + VideoCodec string `json:"video_codec"` + Bitrate int64 `json:"bitrate"` + Audio []AudioTrack `json:"audio_tracks"` + Subtitles []SubTrack `json:"subtitle_tracks"` +} + +type AudioTrack struct { + Index int `json:"index"` + Codec string `json:"codec"` + Language string `json:"language"` + Channels int `json:"channels"` +} + +type SubTrack struct { + Index int `json:"index"` + Codec string `json:"codec"` + Language string `json:"language"` +} + +type ffprobeOutput struct { + Format ffprobeFormat `json:"format"` + Streams []ffprobeStream `json:"streams"` +} + +type ffprobeFormat struct { + Duration string `json:"duration"` + BitRate string `json:"bit_rate"` +} + +type ffprobeStream struct { + Index int `json:"index"` + CodecType string `json:"codec_type"` + CodecName string `json:"codec_name"` + Width int `json:"width"` + Height int `json:"height"` + Channels int `json:"channels"` + Tags struct { + Language string `json:"language"` + } `json:"tags"` +} + +// Run executes ffprobe on the given file and returns parsed metadata. +func Run(path string) (*ProbeResult, error) { + cmd := exec.Command("ffprobe", + "-v", "quiet", + "-print_format", "json", + "-show_format", + "-show_streams", + path, + ) + + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("ffprobe failed: %w", err) + } + + var raw ffprobeOutput + if err := json.Unmarshal(out, &raw); err != nil { + return nil, fmt.Errorf("ffprobe parse failed: %w", err) + } + + result := &ProbeResult{} + + if d, err := strconv.ParseFloat(raw.Format.Duration, 64); err == nil { + result.Duration = d + } + if br, err := strconv.ParseInt(raw.Format.BitRate, 10, 64); err == nil { + result.Bitrate = br + } + + for _, s := range raw.Streams { + switch s.CodecType { + case "video": + if result.VideoCodec == "" { + result.VideoCodec = s.CodecName + result.Width = s.Width + result.Height = s.Height + } + case "audio": + result.Audio = append(result.Audio, AudioTrack{ + Index: s.Index, + Codec: s.CodecName, + Language: s.Tags.Language, + Channels: s.Channels, + }) + case "subtitle": + result.Subtitles = append(result.Subtitles, SubTrack{ + Index: s.Index, + Codec: s.CodecName, + Language: s.Tags.Language, + }) + } + } + + return result, nil +} diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go new file mode 100644 index 0000000..a2f91a0 --- /dev/null +++ b/internal/scanner/scanner.go @@ -0,0 +1,75 @@ +package scanner + +import ( + "os" + "path/filepath" + "strings" + "time" +) + +var videoExtensions = map[string]bool{ + ".mkv": true, + ".mp4": true, + ".avi": true, + ".m4v": true, + ".webm": true, + ".ts": true, + ".mov": true, +} + +type MediaFile struct { + Path string `json:"-"` + RelPath string `json:"rel_path"` + Name string `json:"name"` + Size int64 `json:"size"` + ModTime time.Time `json:"mod_time"` +} + +// DisplayName converts a filename into a human-readable display name. +func DisplayName(filename string) string { + name := strings.TrimSuffix(filename, filepath.Ext(filename)) + name = strings.ReplaceAll(name, ".", " ") + name = strings.ReplaceAll(name, "_", " ") + return name +} + +// Scan recursively walks rootDir and returns all video files found. +func Scan(rootDir string) ([]MediaFile, error) { + var files []MediaFile + + err := filepath.WalkDir(rootDir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil // skip inaccessible paths + } + if d.IsDir() { + return nil + } + + ext := strings.ToLower(filepath.Ext(d.Name())) + if !videoExtensions[ext] { + return nil + } + + info, err := d.Info() + if err != nil { + return nil + } + + relPath, err := filepath.Rel(rootDir, path) + if err != nil { + return nil + } + + files = append(files, MediaFile{ + Path: path, + RelPath: relPath, + Name: DisplayName(d.Name()), + Size: info.Size(), + ModTime: info.ModTime(), + }) + + return nil + }) + + return files, err +}