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>
150 lines
3.3 KiB
Go
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
|
|
}
|