package preview import ( "context" "fmt" "os" "path/filepath" "strings" "sync" "time" "github.com/user/ytdlp-navidrome/internal/config" "github.com/user/ytdlp-navidrome/internal/ytdlp" ) // State represents the per-user dialog state. type State int const ( StateIdle State = iota StateSearchResults StatePreviewSent ) // UserSession holds the current dialog state for a Matrix user. type UserSession struct { State State Query string Results []ytdlp.SearchResult VideoID string PreviewPath string Timestamp time.Time } // Manager handles preview lifecycle and user sessions. type Manager struct { mu sync.RWMutex sessions map[string]*UserSession cfg *config.Config } func NewManager(cfg *config.Config) *Manager { return &Manager{ sessions: make(map[string]*UserSession), cfg: cfg, } } // GetSession returns the current session for a user. func (m *Manager) GetSession(userID string) *UserSession { m.mu.RLock() defer m.mu.RUnlock() return m.sessions[userID] } // SetSearchResults stores search results for a user. func (m *Manager) SetSearchResults(userID, query string, results []ytdlp.SearchResult) { m.mu.Lock() defer m.mu.Unlock() m.sessions[userID] = &UserSession{ State: StateSearchResults, Query: query, Results: results, Timestamp: time.Now(), } } // SetPreviewSent records that a preview was sent for a user. func (m *Manager) SetPreviewSent(userID, videoID, previewPath string) { m.mu.Lock() defer m.mu.Unlock() m.sessions[userID] = &UserSession{ State: StatePreviewSent, VideoID: videoID, PreviewPath: previewPath, Timestamp: time.Now(), } } // ResetSession clears a user session (e.g., on cancel or completion). func (m *Manager) ResetSession(userID string) { m.mu.Lock() defer m.mu.Unlock() delete(m.sessions, userID) } // IsExpired checks if a session has exceeded the preview TTL. func (s *UserSession) IsExpired(ttl time.Duration) bool { return time.Since(s.Timestamp) > ttl } // SanitizeFilename replaces forbidden characters with underscores. func SanitizeFilename(name string) string { forbidden := []string{"/", "\\", ":", "*", "?", "\"", "<", ">", "|"} result := name for _, ch := range forbidden { result = strings.ReplaceAll(result, ch, "_") } return result } // BuildFinalFilename constructs the final filename: "Artist - Title.ext" func BuildFinalFilename(result *ytdlp.SearchResult, ext string) string { artist := result.Channel if artist == "" { artist = "unknown" } title := result.Title if title == "" { title = result.ID } return SanitizeFilename(fmt.Sprintf("%s - %s.%s", artist, title, ext)) } // CleanupOldPreviews removes expired preview files from tmp dir. func (m *Manager) CleanupOldPreviews(ctx context.Context) { ticker := time.NewTicker(5 * time.Minute) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: m.mu.Lock() for userID, session := range m.sessions { if session.State == StatePreviewSent && session.IsExpired(m.cfg.PreviewTTL) { if session.PreviewPath != "" { os.Remove(session.PreviewPath) } delete(m.sessions, userID) } } m.mu.Unlock() } } } // PreviewFilePath returns the expected preview file path for a video ID. func PreviewFilePath(tmpDir, videoID, ext string) string { return filepath.Join(tmpDir, fmt.Sprintf("%s.%s", videoID, ext)) }