200 lines
4.5 KiB
Go
200 lines
4.5 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: "/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 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()
|
|
|
|
type scored struct {
|
|
item MediaItem
|
|
score int
|
|
}
|
|
var results []scored
|
|
for _, item := range l.items {
|
|
name := strings.ToLower(item.Name)
|
|
if s := fuzzyScore(name, q); s > 0 {
|
|
results = append(results, scored{item, s})
|
|
}
|
|
}
|
|
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
|
|
}
|