package service import ( "bytes" "context" "fmt" "log/slog" "path/filepath" "strings" "text/template" "time" "github.com/TopherMayor/unified-media-manager/internal/db" ) type NamingData struct { Title string SortTitle string Year int Season int Episode int Quality string Ext string ReleaseGroup string Resolution string Source string Codec string Artist string Album string Track int Author string Chapter int Date string OriginalName string } type NamingService struct { db *db.DB } func NewNamingService(database *db.DB) *NamingService { return &NamingService{db: database} } var DefaultTemplates = map[string]string{ "movie": "{{sanitize .Title}} ({{.Year}})/{{sanitize .Title}} ({{.Year}}) - {{.Quality}}.{{.Ext}}", "series": "{{sanitize .Title}}/Season {{printf \"%02d\" .Season}}/{{sanitize .Title}} - S{{printf \"%02d\" .Season}}E{{printf \"%02d\" .Episode}} - {{.Quality}}.{{.Ext}}", "music": "{{sanitize .Artist}}/{{sanitize .Album}}/{{printf \"%02d\" .Track}} - {{sanitize .Title}}.{{.Ext}}", "audiobook": "{{sanitize .Author}}/{{sanitize .Title}}/{{sanitize .Title}} - Ch{{printf \"%02d\" .Chapter}}.{{.Ext}}", "podcast": "{{sanitize .Title}}/{{sanitize .Title}} - {{.Date}}.{{.Ext}}", "book": "{{sanitize .Author}}/{{sanitize .Title}} ({{.Year}})/{{sanitize .Title}} ({{.Year}}).{{.Ext}}", } func sanitize(s string) string { s = strings.Map(func(r rune) rune { switch r { case '\\', '/', ':', '*', '?', '"', '<', '>', '|': return -1 } return r }, s) for strings.Contains(s, " ") { s = strings.ReplaceAll(s, " ", " ") } return strings.TrimSpace(s) } func namingFuncMap() template.FuncMap { return template.FuncMap{ "sanitize": sanitize, "lower": strings.ToLower, } } func (s *NamingService) GetTemplate(ctx context.Context, mediaType string) (string, error) { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() var tmpl string err := s.db.Pool.QueryRow(ctx, "SELECT template FROM naming_templates WHERE media_type = $1", mediaType).Scan(&tmpl) if err != nil { if fallback, ok := DefaultTemplates[mediaType]; ok { return fallback, nil } return "", fmt.Errorf("get naming template: %w", err) } return tmpl, nil } func (s *NamingService) Render(ctx context.Context, mediaType string, data NamingData) (string, error) { tmplStr, err := s.GetTemplate(ctx, mediaType) if err != nil { return "", fmt.Errorf("get template for render: %w", err) } tmpl, err := template.New("naming").Funcs(namingFuncMap()).Parse(tmplStr) if err != nil { slog.Error("failed to parse naming template", "error", err, "media_type", mediaType) return "", fmt.Errorf("parse naming template: %w", err) } var buf bytes.Buffer if err := tmpl.Execute(&buf, data); err != nil { slog.Error("failed to execute naming template", "error", err, "media_type", mediaType) return "", fmt.Errorf("execute naming template: %w", err) } return buf.String(), nil } func ExtractExt(filename string) string { ext := filepath.Ext(filename) if ext == "" { return "" } return strings.TrimPrefix(ext, ".") }