sheepflix/internal/library/library.go
Olive Vaughn 31cd97e267 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>
2026-08-26 00:08:01 -04:00

150 lines
3.3 KiB
Go

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
}