sheepflix/internal/library/library.go

201 lines
4.5 KiB
Go
Raw Permalink Normal View History

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,
2026-08-26 02:16:50 -04:00
StreamURL: "/vod/" + id + "/media/" + 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
}
2026-08-26 00:34:45 -04:00
// Search returns items matching the query, ranked by fuzzy match score.
func (l *Library) Search(query string) []MediaItem {
q := strings.ToLower(query)
l.mu.RLock()
defer l.mu.RUnlock()
2026-08-26 00:34:45 -04:00
type scored struct {
item MediaItem
score int
}
var results []scored
for _, item := range l.items {
2026-08-26 00:34:45 -04:00
name := strings.ToLower(item.Name)
if s := fuzzyScore(name, q); s > 0 {
results = append(results, scored{item, s})
}
}
2026-08-26 00:34:45 -04:00
sort.Slice(results, func(i, j int) bool {
return results[i].score > results[j].score
})
items := make([]MediaItem, len(results))
for i, r := range results {
items[i] = r.item
}
return items
}
// fuzzyScore returns a match score for how well target matches the query.
// Returns 0 if no match. Higher is better.
// Exact substring match gets a big bonus. Otherwise uses subsequence matching
// with bonuses for consecutive chars and word-boundary matches.
func fuzzyScore(target, query string) int {
// exact substring = best possible match
if strings.Contains(target, query) {
return 1000 + len(query)*10
}
// subsequence match with scoring
qi := 0
score := 0
consecutive := 0
for ti := 0; ti < len(target) && qi < len(query); ti++ {
if target[ti] == query[qi] {
score += 10
consecutive++
if consecutive > 1 {
score += consecutive * 5 // bonus for runs
}
// word boundary bonus
if ti == 0 || target[ti-1] == ' ' || target[ti-1] == '.' || target[ti-1] == '_' || target[ti-1] == '-' {
score += 20
}
qi++
} else {
consecutive = 0
}
}
if qi < len(query) {
return 0 // didn't match all query chars
}
return score
}