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
28
Dockerfile
Normal file
28
Dockerfile
Normal 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
39
cmd/sheepflix/main.go
Normal 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
33
config.example.yaml
Normal 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
13
docker-compose.yml
Normal 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
12
entrypoint.sh
Normal 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
5
go.mod
Normal 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
2
go.sum
Normal 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
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