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 <noreply@anthropic.com>
This commit is contained in:
Olive Vaughn 2026-08-26 00:08:01 -04:00
commit 6625e5927e
11 changed files with 603 additions and 0 deletions

28
Dockerfile Normal file
View file

@ -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"]

39
cmd/sheepflix/main.go Normal file
View file

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

33
config.example.yaml Normal file
View file

@ -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

13
docker-compose.yml Normal file
View file

@ -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

12
entrypoint.sh Normal file
View file

@ -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

5
go.mod Normal file
View file

@ -0,0 +1,5 @@
module sheepflix
go 1.23.3
require github.com/go-chi/chi/v5 v5.3.2

2
go.sum Normal file
View file

@ -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=

137
internal/api/api.go Normal file
View file

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

150
internal/library/library.go Normal file
View file

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

109
internal/probe/probe.go Normal file
View file

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

View file

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