143 lines
3.3 KiB
Go
143 lines
3.3 KiB
Go
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))
|
|
}
|