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 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 }