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
31cd97e267
11 changed files with 631 additions and 0 deletions
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue