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 }