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:
commit
6625e5927e
11 changed files with 603 additions and 0 deletions
137
internal/api/api.go
Normal file
137
internal/api/api.go
Normal 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
150
internal/library/library.go
Normal 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
109
internal/probe/probe.go
Normal 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
|
||||
}
|
||||
75
internal/scanner/scanner.go
Normal file
75
internal/scanner/scanner.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue