From a8cd06ab10a6bc807b954a8e2f371b6cac735feb Mon Sep 17 00:00:00 2001 From: Calmcacil B <716671+calmcacil@users.noreply.github.com> Date: Mon, 1 Jun 2026 20:21:32 +0200 Subject: [PATCH] feat: add media-airtime command for fuzzy Sonarr/Radarr airtime lookup --- Makefile | 5 +- README.md | 23 ++ cmd/calmstoolkit-setup/main.go | 8 +- cmd/media-airtime/main.go | 81 ++++ cmd/media-airtime/main_test.go | 22 ++ internal/airtime/airtime.go | 660 +++++++++++++++++++++++++++++++ internal/airtime/airtime_test.go | 354 +++++++++++++++++ internal/airtime/render.go | 359 +++++++++++++++++ internal/airtime/score.go | 133 +++++++ internal/config/config.go | 25 ++ 10 files changed, 1668 insertions(+), 2 deletions(-) create mode 100644 cmd/media-airtime/main.go create mode 100644 cmd/media-airtime/main_test.go create mode 100644 internal/airtime/airtime.go create mode 100644 internal/airtime/airtime_test.go create mode 100644 internal/airtime/render.go create mode 100644 internal/airtime/score.go diff --git a/Makefile b/Makefile index 62d37c3..8c63d96 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,7 @@ BINARY_STREAMSTOOL=media-streams BINARY_CALENDAR=media-calendar BINARY_REQUESTS=media-requests BINARY_ARRFEED=arr-feed +BINARY_AIRTIME=media-airtime BINARY_SETUP=calmstoolkit-setup # Go parameters @@ -48,6 +49,7 @@ build: $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_CALENDAR) ./cmd/media-calendar/ $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_REQUESTS) ./cmd/media-requests/ $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_ARRFEED) ./cmd/arr-feed/ + $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_AIRTIME) ./cmd/media-airtime/ @echo "Build complete: $(BUILD_DIR)/*" build-all: @@ -56,7 +58,7 @@ build-all: @for GOOS in linux darwin windows freebsd; do \ for GOARCH in amd64 arm64; do \ - for SRC in media-streams media-calendar media-requests arr-feed; do \ + for SRC in media-streams media-calendar media-requests arr-feed media-airtime; do \ EXT=""; \ if [ "$$GOOS" = "windows" ]; then EXT=".exe"; fi; \ BIN=$${SRC}-$$GOOS-$$GOARCH$$EXT; \ @@ -75,6 +77,7 @@ install: build @install -m 755 $(BUILD_DIR)/$(BINARY_CALENDAR) $(INSTALL_DIR)/$(BINARY_CALENDAR) @install -m 755 $(BUILD_DIR)/$(BINARY_REQUESTS) $(INSTALL_DIR)/$(BINARY_REQUESTS) @install -m 755 $(BUILD_DIR)/$(BINARY_ARRFEED) $(INSTALL_DIR)/$(BINARY_ARRFEED) + @install -m 755 $(BUILD_DIR)/$(BINARY_AIRTIME) $(INSTALL_DIR)/$(BINARY_AIRTIME) @echo "Make sure $(INSTALL_DIR) is in your PATH." @echo "Installation complete!" diff --git a/README.md b/README.md index 18e4d2d..5cb3b5c 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,29 @@ Display upcoming TV episodes and movie releases from Sonarr and Radarr in a conc ./bin/media-calendar -json ``` +### Media Airtime + +Look up next upcoming and last aired dates for any show or movie in your Sonarr/Radarr library with fuzzy matching and an interactive selection prompt. + +#### Features + +- Fuzzy Search: Find shows and movies with partial or approximate name matching +- Interactive Selection: Pick from multiple matches with a numbered menu +- Airtime Display: Shows next upcoming airtime or last aired episode with relative dates ("in 3 days", "yesterday") +- Per-Season Detail: Current season detection with episode on-disk status +- Multi-Instance: Searches across all configured Sonarr and Radarr instances +- JSON Output: Machine-readable format for automation + +#### Quick Start + +```bash +./bin/media-airtime "clarkson" +./bin/media-airtime "clarkson's farm" +./bin/media-airtime -type movie "inception 2010" +./bin/media-airtime -exact "Clarkson's Farm" +./bin/media-airtime -json "dune" +``` + ### ARR Feed Monitor Sonarr and Radarr history events (grabbed, imported, failed) in real-time. diff --git a/cmd/calmstoolkit-setup/main.go b/cmd/calmstoolkit-setup/main.go index f9215d3..359d084 100644 --- a/cmd/calmstoolkit-setup/main.go +++ b/cmd/calmstoolkit-setup/main.go @@ -111,9 +111,14 @@ func main() { fmt.Println() fmt.Println(strings.Repeat("─", 50)) - fmt.Println(" Step 6/6: Calendar & Feed Settings") + fmt.Println(" Step 6/6: Airtime, Calendar & Feed Settings") fmt.Println(strings.Repeat("─", 50)) + fmt.Println("\n -- Media Airtime --") + cfg.MediaAirtime.Limit = promptInt("Search result limit (1-50)", cfg.MediaAirtime.Limit) + cfg.MediaAirtime.PastDays = promptInt("Past days for last-aired lookup", cfg.MediaAirtime.PastDays) + cfg.MediaAirtime.FutureDays = promptInt("Future days for next-upcoming lookup", cfg.MediaAirtime.FutureDays) + fmt.Println("\n -- Media Calendar --") cfg.MediaCalendar.Days = promptInt("Days to show", cfg.MediaCalendar.Days) cfg.MediaCalendar.DaysPast = promptInt("Past days to include", cfg.MediaCalendar.DaysPast) @@ -140,6 +145,7 @@ func main() { fmt.Printf(" Plex configured: %v\n", cfg.MediaStreams.PlexToken != "") fmt.Printf(" Jellyfin configured: %v\n", cfg.MediaStreams.JellyfinToken != "") fmt.Printf(" Requests server: %v\n", cfg.MediaRequests.APIKey != "") + fmt.Printf(" Airtime limit: %d\n", cfg.MediaAirtime.Limit) fmt.Printf(" Calendar days: %d (+%d past)\n", cfg.MediaCalendar.Days, cfg.MediaCalendar.DaysPast) fmt.Printf(" Feed max events: %d\n", cfg.ArrFeed.MaxEvents) fmt.Printf(" Feed subtitles: %v\n", cfg.ArrFeed.ShowSubtitles) diff --git a/cmd/media-airtime/main.go b/cmd/media-airtime/main.go new file mode 100644 index 0000000..1e9b632 --- /dev/null +++ b/cmd/media-airtime/main.go @@ -0,0 +1,81 @@ +package main + +import ( + "flag" + "fmt" + "os" + "strings" + + "github.com/calmcacil/CalmsToolkit/internal/airtime" + "github.com/calmcacil/CalmsToolkit/internal/colors" + "github.com/calmcacil/CalmsToolkit/internal/config" +) + +func main() { + tk, err := config.LoadToolkitConfig() + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: %v\n", err) + } + cfg := airtime.BuildToolConfig(tk) + + searchType := flag.String("type", "auto", "Search scope: auto, series, movie") + limit := flag.Int("limit", cfg.Limit, "Maximum candidates to list") + exact := flag.Bool("exact", false, "Require exact title match") + season := flag.Int("season", 0, "Override season number (0 = auto-detect)") + pastDays := flag.Int("past", cfg.PastDays, "Past days for last-aired lookup") + futureDays := flag.Int("future", cfg.FutureDays, "Future days for next-upcoming lookup") + timeout := flag.Duration("timeout", cfg.Timeout, "HTTP connection timeout") + theme := flag.String("theme", cfg.Theme, "Color theme (default, catppuccin-mocha, catppuccin-latte)") + noColor := flag.Bool("no-color", cfg.NoColor, "Disable colored output") + jsonOutput := flag.Bool("json", false, "Output in JSON format") + debug := flag.Bool("debug", cfg.Debug, "Enable debug logging") + noBanner := flag.Bool("no-banner", false, "Suppress decorative banner") + flag.Parse() + + query := strings.TrimSpace(strings.Join(flag.Args(), " ")) + if query == "" { + fmt.Fprintf(os.Stderr, "Usage: media-airtime [flags]\n") + fmt.Fprintf(os.Stderr, "\nFlags:\n") + flag.PrintDefaults() + os.Exit(1) + } + + cfg.SearchType = *searchType + cfg.Limit = *limit + cfg.Exact = *exact + cfg.Season = *season + cfg.PastDays = *pastDays + cfg.FutureDays = *futureDays + cfg.Timeout = *timeout + cfg.NoColor = *noColor || *jsonOutput + cfg.Theme = *theme + if cfg.Theme != "" && !colors.ValidateTheme(cfg.Theme) { + fmt.Fprintf(os.Stderr, "Warning: unknown theme %q, falling back to default (valid: %s)\n", + cfg.Theme, strings.Join(colors.ValidThemes(), ", ")) + cfg.Theme = "default" + } + cfg.JSONOutput = *jsonOutput + cfg.Debug = *debug + cfg.NoBanner = *noBanner + + if tk != nil { + if err := tk.Validate(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: config validation: %v\n", err) + } + } + + if searchType := *searchType; searchType != "auto" && searchType != "series" && searchType != "movie" { + fmt.Fprintf(os.Stderr, "ERROR: -type must be auto, series, or movie\n") + os.Exit(1) + } + if cfg.Limit < 1 || cfg.Limit > 50 { + fmt.Fprintf(os.Stderr, "ERROR: -limit must be between 1 and 50\n") + os.Exit(1) + } + if cfg.Timeout <= 0 { + fmt.Fprintf(os.Stderr, "ERROR: -timeout must be positive\n") + os.Exit(1) + } + + airtime.Run(query, cfg) +} diff --git a/cmd/media-airtime/main_test.go b/cmd/media-airtime/main_test.go new file mode 100644 index 0000000..9de072c --- /dev/null +++ b/cmd/media-airtime/main_test.go @@ -0,0 +1,22 @@ +package main + +import ( + "os" + "os/exec" + "testing" +) + +func TestMainHelpExitsOnNoArgs(t *testing.T) { + if os.Getenv("GO_TEST_SUBPROCESS") == "1" { + os.Args = []string{"media-airtime"} + main() + return + } + cmd := exec.Command(os.Args[0], "-test.run=TestMainHelpExitsOnNoArgs") + cmd.Env = append(os.Environ(), "GO_TEST_SUBPROCESS=1") + err := cmd.Run() + if e, ok := err.(*exec.ExitError); ok && !e.Success() { + return + } + t.Fatal("expected exit 1 for no args") +} diff --git a/internal/airtime/airtime.go b/internal/airtime/airtime.go new file mode 100644 index 0000000..136234f --- /dev/null +++ b/internal/airtime/airtime.go @@ -0,0 +1,660 @@ +package airtime + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/signal" + "slices" + "sort" + "strings" + "sync" + "syscall" + "time" + + "github.com/calmcacil/CalmsToolkit/internal/config" + "github.com/calmcacil/CalmsToolkit/internal/httputil" +) + +// SonarrSeries represents a single series from Sonarr /api/v3/series. +type SonarrSeries struct { + ID int `json:"id"` + Title string `json:"title"` + Year int `json:"year"` + TVDBID int `json:"tvdbId"` + Monitored bool `json:"monitored"` + Status string `json:"status"` + Seasons []SonarrSeason `json:"seasons"` + SeasonCount int `json:"seasonCount"` + Genres []string `json:"genres"` + Runtime int `json:"runtime"` + TitleSlug string `json:"titleSlug"` + Images []SonarrImage `json:"images"` + RemotePoster string `json:"remotePoster,omitempty"` +} + +type SonarrSeason struct { + SeasonNumber int `json:"seasonNumber"` + Monitored bool `json:"monitored"` + Statistics *SonarrStats `json:"statistics,omitempty"` +} + +type SonarrStats struct { + EpisodeCount int `json:"episodeCount"` + EpisodeFileCount int `json:"episodeFileCount"` + TotalEpisodeCount int `json:"totalEpisodeCount"` +} + +type SonarrImage struct { + CoverType string `json:"coverType"` + URL string `json:"url"` +} + +// RadarrMovie represents a movie from Radarr /api/v3/movie. +type RadarrMovie struct { + ID int `json:"id"` + Title string `json:"title"` + Year int `json:"year"` + TMDBID int `json:"tmdbId"` + Monitored bool `json:"monitored"` + HasFile bool `json:"hasFile"` + IsAvailable bool `json:"isAvailable"` + Status string `json:"status"` + DigitalRelease string `json:"digitalRelease"` + PhysicalRelease string `json:"physicalRelease"` + InCinemas string `json:"inCinemas"` + Runtime int `json:"runtime"` + Genres []string `json:"genres"` + TitleSlug string `json:"titleSlug"` +} + +// SonarrEpisode represents a single episode with airtime. +type SonarrEpisode struct { + ID int `json:"id"` + Title string `json:"title"` + AirDateUtc time.Time `json:"airDateUtc"` + SeasonNumber int `json:"seasonNumber"` + EpisodeNumber int `json:"episodeNumber"` + Monitored bool `json:"monitored"` + HasFile bool `json:"hasFile"` +} + +// SeriesOrMovie is a unified candidate for searching. +type SeriesOrMovie struct { + Type string // "series" or "movie" + Title string + Year int + TVDBID int + TMDBID int + Monitored bool + HasFile bool + Status string + Seasons int + Source string // instance name + Instance config.ArrInstance + RawID int // series or movie ID for follow-up queries + DigitalRelease string + PhysicalRelease string + InCinemas string +} + +// AirtimeInfo holds the resolved airtime for a matched item. +type AirtimeInfo struct { + Title string + Year int + Source string + Type string // "series" or "movie" + + Status string // "ongoing", "ended", "released", "announced", "tba" + SeasonCount int + Season int // current season number + EpisodesOnDisk int + EpisodesTotal int + Monitored bool + + LastAir *time.Time + LastLabel string + NextAir *time.Time + NextLabel string + TVDBID int + TMDBID int +} + +// SelectionEvent is emitted when the user picks a candidate. +type SelectionEvent struct { + Index int + Query string +} + +// ToolConfig holds configuration for the media-airtime tool. +type ToolConfig struct { + SonarrInstances []config.ArrInstance + RadarrInstances []config.ArrInstance + SearchType string // "auto", "series", "movie" + Limit int + Exact bool + Season int + PastDays int + FutureDays int + Timeout time.Duration + NoColor bool + Theme string + JSONOutput bool + Debug bool + NoBanner bool +} + +// BuildToolConfig constructs ToolConfig from the global config. +func BuildToolConfig(tk *config.ToolkitConfig) ToolConfig { + cfg := ToolConfig{} + if tk == nil { + cfg.Timeout = 10 * time.Second + cfg.Limit = 10 + cfg.PastDays = 7 + cfg.FutureDays = 30 + cfg.SearchType = "auto" + return cfg + } + dur, err := time.ParseDuration(tk.General.Timeout) + if err != nil || dur <= 0 { + dur = 10 * time.Second + } + cfg.Timeout = dur + cfg.NoColor = tk.General.NoColor + cfg.Theme = tk.General.Theme + cfg.SonarrInstances = slices.Clone(tk.Sonarr) + cfg.RadarrInstances = slices.Clone(tk.Radarr) + cfg.SearchType = "auto" + + if tk.MediaAirtime.Limit > 0 { + cfg.Limit = tk.MediaAirtime.Limit + } else { + cfg.Limit = 10 + } + if tk.MediaAirtime.PastDays > 0 { + cfg.PastDays = tk.MediaAirtime.PastDays + } else { + cfg.PastDays = 7 + } + if tk.MediaAirtime.FutureDays > 0 { + cfg.FutureDays = tk.MediaAirtime.FutureDays + } else { + cfg.FutureDays = 30 + } + cfg.Debug = tk.MediaAirtime.Debug + return cfg +} + +// Run executes the media-airtime tool. +func Run(query string, cfg ToolConfig) { + if len(cfg.SonarrInstances) == 0 && len(cfg.RadarrInstances) == 0 { + fmt.Fprintf(os.Stderr, "ERROR: No Sonarr or Radarr instances configured\n") + fmt.Fprintf(os.Stderr, "Run 'make setup' or edit ~/.config/calmstoolkit/config.json\n") + os.Exit(1) + } + + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + client := httputil.NewTransportClient(cfg.Timeout) + + candidates := searchLibrary(ctx, client, query, cfg) + if len(candidates) == 0 { + if cfg.JSONOutput { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + encoder.Encode(map[string]interface{}{ + "query": query, + "candidates": []map[string]interface{}{}, + "selected": nil, + "airtime": nil, + "error": "no matches found", + }) + return + } + fmt.Fprintf(os.Stderr, "No matches found for %q\n", query) + os.Exit(2) + } + + if cfg.Debug { + fmt.Fprintf(os.Stderr, "DEBUG: %d candidate(s) found\n", len(candidates)) + for _, c := range candidates { + fmt.Fprintf(os.Stderr, "DEBUG: [%d] %s (%d) type=%s source=%s score=%d\n", + c.Candidate.RawID, c.Candidate.Title, c.Candidate.Year, + c.Candidate.Type, c.Candidate.Source, c.Score) + } + } + + selected := pickCandidate(ctx, candidates, query, cfg) + if selected == nil { + os.Exit(3) + } + + info := resolveAirtime(ctx, client, *selected, cfg) + if cfg.JSONOutput { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + candOut := make([]map[string]interface{}, len(candidates)) + for i, sm := range candidates { + candOut[i] = candidateToMap(sm.Candidate, sm.Score) + } + encoder.Encode(map[string]interface{}{ + "query": query, + "candidates": candOut, + "selected": candidateToMap(selected.Candidate, selected.Score), + "airtime": infoToMap(info), + }) + return + } + + renderCard(info, cfg) +} + +func searchLibrary(ctx context.Context, client *httputil.Client, query string, cfg ToolConfig) []scoredMatch { + var mu sync.Mutex + all := make([]scoredMatch, 0, 100) + var g sync.WaitGroup + + includeSeries := cfg.SearchType == "auto" || cfg.SearchType == "series" + includeMovies := cfg.SearchType == "auto" || cfg.SearchType == "movie" + + if includeSeries { + for _, inst := range cfg.SonarrInstances { + inst := inst + g.Add(1) + go func() { + defer g.Done() + series, err := fetchSonarrLibrary(ctx, client, inst, cfg.Debug) + if err != nil { + if cfg.Debug { + fmt.Fprintf(os.Stderr, "DEBUG: Sonarr %s: %v\n", inst.Name, err) + } + return + } + mu.Lock() + for _, s := range series { + c := SeriesOrMovie{ + Type: "series", + Title: s.Title, + Year: s.Year, + TVDBID: s.TVDBID, + Monitored: s.Monitored, + Status: s.Status, + Seasons: s.SeasonCount, + Source: inst.Name, + Instance: inst, + RawID: s.ID, + } + score := scoreCandidate(query, c) + if score > 0 { + all = append(all, scoredMatch{Candidate: c, Score: score}) + } + } + mu.Unlock() + }() + } + } + + if includeMovies { + for _, inst := range cfg.RadarrInstances { + inst := inst + g.Add(1) + go func() { + defer g.Done() + movies, err := fetchRadarrLibrary(ctx, client, inst, cfg.Debug) + if err != nil { + if cfg.Debug { + fmt.Fprintf(os.Stderr, "DEBUG: Radarr %s: %v\n", inst.Name, err) + } + return + } + mu.Lock() + for _, m := range movies { + c := SeriesOrMovie{ + Type: "movie", + Title: m.Title, + Year: m.Year, + TMDBID: m.TMDBID, + Monitored: m.Monitored, + HasFile: m.HasFile, + Status: m.Status, + Source: inst.Name, + Instance: inst, + RawID: m.ID, + DigitalRelease: m.DigitalRelease, + PhysicalRelease: m.PhysicalRelease, + InCinemas: m.InCinemas, + } + score := scoreCandidate(query, c) + if score > 0 { + all = append(all, scoredMatch{Candidate: c, Score: score}) + } + } + mu.Unlock() + }() + } + } + + g.Wait() + + if cfg.Exact && len(all) > 0 { + q := strings.ToLower(query) + var filtered []scoredMatch + for _, sm := range all { + if strings.EqualFold(strings.ToLower(sm.Candidate.Title), q) { + filtered = append(filtered, sm) + } + } + all = filtered + } + + sort.Slice(all, func(i, j int) bool { + if all[i].Score != all[j].Score { + return all[i].Score > all[j].Score + } + if all[i].Candidate.Year != all[j].Candidate.Year { + return all[i].Candidate.Year > all[j].Candidate.Year + } + return all[i].Candidate.Title < all[j].Candidate.Title + }) + + if len(all) > cfg.Limit { + all = all[:cfg.Limit] + } + + if cfg.Debug { + fmt.Fprintf(os.Stderr, "DEBUG: search returned %d candidate(s)\n", len(all)) + for i, sm := range all { + fmt.Fprintf(os.Stderr, "DEBUG: %d. %s (%d) score=%d type=%s src=%s\n", + i+1, sm.Candidate.Title, sm.Candidate.Year, sm.Score, + sm.Candidate.Type, sm.Candidate.Source) + } + } + + return all +} + +func fetchSonarrLibrary(ctx context.Context, client *httputil.Client, inst config.ArrInstance, debug bool) ([]SonarrSeries, error) { + url := inst.URL + "/api/v3/series" + if debug { + fmt.Fprintf(os.Stderr, "DEBUG: Sonarr %s: GET %s\n", inst.Name, url) + } + headers := map[string]string{"X-Api-Key": inst.APIKey} + var series []SonarrSeries + if err := client.DoJSON(ctx, "GET", url, headers, nil, &series); err != nil { + return nil, err + } + return series, nil +} + +func fetchRadarrLibrary(ctx context.Context, client *httputil.Client, inst config.ArrInstance, debug bool) ([]RadarrMovie, error) { + url := inst.URL + "/api/v3/movie" + if debug { + fmt.Fprintf(os.Stderr, "DEBUG: Radarr %s: GET %s\n", inst.Name, url) + } + headers := map[string]string{"X-Api-Key": inst.APIKey} + var movies []RadarrMovie + if err := client.DoJSON(ctx, "GET", url, headers, nil, &movies); err != nil { + return nil, err + } + return movies, nil +} + +func fetchSonarrEpisodes(ctx context.Context, client *httputil.Client, inst config.ArrInstance, seriesID int, debug bool) ([]SonarrEpisode, error) { + url := fmt.Sprintf("%s/api/v3/episode?seriesId=%d", inst.URL, seriesID) + if debug { + fmt.Fprintf(os.Stderr, "DEBUG: Sonarr %s: GET %s\n", inst.Name, url) + } + headers := map[string]string{"X-Api-Key": inst.APIKey} + var episodes []SonarrEpisode + if err := client.DoJSON(ctx, "GET", url, headers, nil, &episodes); err != nil { + return nil, err + } + return episodes, nil +} + +func resolveAirtime(ctx context.Context, client *httputil.Client, selected scoredMatch, cfg ToolConfig) AirtimeInfo { + c := selected.Candidate + info := AirtimeInfo{ + Title: c.Title, + Year: c.Year, + Source: c.Source, + Type: c.Type, + Monitored: c.Monitored, + TVDBID: c.TVDBID, + TMDBID: c.TMDBID, + } + + if c.Type == "series" { + episodes, err := fetchSonarrEpisodes(ctx, client, c.Instance, c.RawID, cfg.Debug) + if err != nil { + if cfg.Debug { + fmt.Fprintf(os.Stderr, "DEBUG: error fetching episodes for %q: %v\n", c.Title, err) + } + info.Status = "unknown" + return info + } + + if len(episodes) == 0 { + info.Status = "tba" + info.SeasonCount = c.Seasons + return info + } + + seasonEpisodes := groupEpisodesBySeason(episodes) + + if cfg.Season > 0 { + info.Season = cfg.Season + } else { + info.Season = findCurrentSeason(seasonEpisodes) + } + + if season, ok := seasonEpisodes[info.Season]; ok { + info.SeasonCount = len(seasonEpisodes) + info.EpisodesOnDisk = countOnDisk(season) + info.EpisodesTotal = len(season) + + sort.Slice(season, func(i, j int) bool { + return season[i].AirDateUtc.Before(season[j].AirDateUtc) + }) + + var lastAir, nextAir *time.Time + var lastLabel, nextLabel string + now := time.Now() + + for _, ep := range season { + if ep.AirDateUtc.IsZero() { + continue + } + if !ep.AirDateUtc.After(now) { + t := ep.AirDateUtc + lastAir = &t + lastLabel = fmt.Sprintf("S%02dE%02d %q", ep.SeasonNumber, ep.EpisodeNumber, ep.Title) + } else if nextAir == nil { + t := ep.AirDateUtc + nextAir = &t + nextLabel = fmt.Sprintf("S%02dE%02d %q", ep.SeasonNumber, ep.EpisodeNumber, ep.Title) + } + } + + info.LastAir = lastAir + info.LastLabel = lastLabel + info.NextAir = nextAir + info.NextLabel = nextLabel + + if info.NextAir == nil && info.LastAir != nil { + info.Status = "ended" + } else if info.NextAir != nil { + info.Status = "ongoing" + } else { + info.Status = "tba" + } + } else { + info.Status = "tba" + } + } else { + info.Status = resolveMovieStatus(c, cfg) + now := time.Now() + + var releaseTime time.Time + if c.DigitalRelease != "" { + releaseTime, _ = parseDateFlexible(c.DigitalRelease) + } + if releaseTime.IsZero() && c.PhysicalRelease != "" { + releaseTime, _ = parseDateFlexible(c.PhysicalRelease) + } + if releaseTime.IsZero() && c.InCinemas != "" { + releaseTime, _ = parseDateFlexible(c.InCinemas) + } + if !releaseTime.IsZero() { + if releaseTime.After(now) { + info.NextAir = &releaseTime + info.NextLabel = "Theatrical / Digital release" + } else { + info.LastAir = &releaseTime + info.LastLabel = "Release date" + } + } + } + + return info +} + +type episodeBySeason map[int][]SonarrEpisode + +func groupEpisodesBySeason(episodes []SonarrEpisode) episodeBySeason { + m := make(episodeBySeason) + for _, ep := range episodes { + m[ep.SeasonNumber] = append(m[ep.SeasonNumber], ep) + } + return m +} + +func findCurrentSeason(seasons episodeBySeason) int { + now := time.Now().Add(7 * 24 * time.Hour) + bestSeason := 1 + bestDiff := time.Duration(1<<63 - 1) + + for sn, episodes := range seasons { + for _, ep := range episodes { + if ep.AirDateUtc.IsZero() { + continue + } + diff := ep.AirDateUtc.Sub(now) + if diff < 0 { + diff = -diff + } + if diff < bestDiff { + bestDiff = diff + bestSeason = sn + } + } + } + return bestSeason +} + +func countOnDisk(episodes []SonarrEpisode) int { + n := 0 + for _, ep := range episodes { + if ep.HasFile { + n++ + } + } + return n +} + +func resolveMovieStatus(c SeriesOrMovie, cfg ToolConfig) string { + if c.Status == "released" || c.HasFile { + return "released" + } + return "announced" +} + +func parseDateFlexible(s string) (time.Time, error) { + formats := []string{ + time.RFC3339, + time.RFC3339Nano, + "2006-01-02T15:04:05Z", + "2006-01-02", + } + for _, f := range formats { + if t, err := time.Parse(f, s); err == nil { + return t, nil + } + } + return time.Time{}, fmt.Errorf("unrecognized date format: %s", s) +} + +func getMovieReleaseTime(m RadarrMovie) time.Time { + var releaseTime time.Time + if m.DigitalRelease != "" { + releaseTime, _ = parseDateFlexible(m.DigitalRelease) + } + if releaseTime.IsZero() && m.PhysicalRelease != "" { + releaseTime, _ = parseDateFlexible(m.PhysicalRelease) + } + if releaseTime.IsZero() && m.InCinemas != "" { + releaseTime, _ = parseDateFlexible(m.InCinemas) + } + return releaseTime +} + +func candidateToMap(c SeriesOrMovie, score int) map[string]interface{} { + return map[string]interface{}{ + "type": c.Type, + "title": c.Title, + "year": c.Year, + "tvdb_id": c.TVDBID, + "tmdb_id": c.TMDBID, + "monitored": c.Monitored, + "has_file": c.HasFile, + "status": c.Status, + "seasons": c.Seasons, + "source": c.Source, + "raw_id": c.RawID, + "score": score, + } +} + +func infoToMap(i AirtimeInfo) map[string]interface{} { + m := map[string]interface{}{ + "title": i.Title, + "year": i.Year, + "source": i.Source, + "type": i.Type, + "status": i.Status, + "season_count": i.SeasonCount, + "season": i.Season, + "episodes_on_disk": i.EpisodesOnDisk, + "episodes_total": i.EpisodesTotal, + "monitored": i.Monitored, + "tvdb_id": i.TVDBID, + "tmdb_id": i.TMDBID, + } + if i.LastAir != nil { + m["last_air"] = i.LastAir.Format(time.RFC3339) + m["last_label"] = i.LastLabel + } + if i.NextAir != nil { + m["next_air"] = i.NextAir.Format(time.RFC3339) + m["next_label"] = i.NextLabel + } + return m +} + +func pickCandidate(ctx context.Context, candidates []scoredMatch, query string, cfg ToolConfig) *scoredMatch { + if len(candidates) == 0 { + return nil + } + + if len(candidates) == 1 { + return &candidates[0] + } + + if cfg.JSONOutput { + return &candidates[0] + } + + return interactiveSelect(ctx, candidates, query, cfg) +} diff --git a/internal/airtime/airtime_test.go b/internal/airtime/airtime_test.go new file mode 100644 index 0000000..3e16b97 --- /dev/null +++ b/internal/airtime/airtime_test.go @@ -0,0 +1,354 @@ +package airtime + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/calmcacil/CalmsToolkit/internal/config" + "github.com/calmcacil/CalmsToolkit/internal/httputil" +) + +func TestScoreContainment(t *testing.T) { + tests := []struct { + query string + title string + year int + want bool + }{ + {"clarkson", "Clarkson's Farm", 2021, true}, + {"clark", "Clarkson's Farm", 2021, true}, + {"clarkson farm", "Clarkson's Farm", 2021, true}, + {"xyzzy", "Clarkson's Farm", 2021, false}, + {"bob", "Clarkson's Farm", 2021, false}, + } + for _, tc := range tests { + c := SeriesOrMovie{Title: tc.title, Year: tc.year, Monitored: true} + got := scoreCandidate(tc.query, c) > 0 + if got != tc.want { + t.Errorf("scoreCandidate(%q, %q) > 0 = %v, want %v", tc.query, tc.title, got, tc.want) + } + } +} + +func TestScoreRanking(t *testing.T) { + // "clarkson" should score higher for Clarkson's Farm than for "Clarkson's Other Show" + high := scoreCandidate("clarkson", SeriesOrMovie{Title: "Clarkson's Farm", Year: 2021, Monitored: true}) + low := scoreCandidate("clarkson", SeriesOrMovie{Title: "Bob Clarkson's Other Show", Year: 2020, Monitored: true}) + if high <= low { + t.Errorf("clarkson's farm (%d) should beat other show (%d)", high, low) + } +} + +func TestScoreExact(t *testing.T) { + // exact match should score high + c := SeriesOrMovie{Title: "Clarkson's Farm", Year: 2021, Monitored: true} + s := scoreCandidate("clarkson's farm", c) + if s <= 50 { + t.Errorf("exact match score %d too low", s) + } +} + +func TestScoreWithYear(t *testing.T) { + // year in query should boost + withYear := scoreCandidate("clarkson 2021", SeriesOrMovie{Title: "Clarkson's Farm", Year: 2021, Monitored: true}) + withoutYear := scoreCandidate("clarkson", SeriesOrMovie{Title: "Clarkson's Farm", Year: 2021, Monitored: true}) + if withYear <= withoutYear { + t.Errorf("year boost should increase score: with=%d without=%d", withYear, withoutYear) + } +} + +func TestScoreZeroForNoMatch(t *testing.T) { + s := scoreCandidate("totally unrelated", SeriesOrMovie{Title: "Clarkson's Farm", Year: 2021}) + if s != 0 { + t.Errorf("expected score 0, got %d", s) + } +} + +func TestScoreInLibraryBoost(t *testing.T) { + monitored := scoreCandidate("clarkson", SeriesOrMovie{Title: "Clarkson's Farm", Year: 2021, Monitored: true}) + unmonitored := scoreCandidate("clarkson", SeriesOrMovie{Title: "Clarkson's Farm", Year: 2021, Monitored: false}) + if monitored <= unmonitored { + t.Errorf("library boost should increase score: monitored=%d unmonitored=%d", monitored, unmonitored) + } +} + +func TestFetchSonarrLibrary(t *testing.T) { + mockResponse := []SonarrSeries{ + { + ID: 1, + Title: "Test Show", + Year: 2024, + TVDBID: 12345, + Monitored: true, + Status: "continuing", + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Errorf("Expected GET, got %s", r.Method) + } + if r.Header.Get("X-Api-Key") != "test-token" { + t.Errorf("Expected X-Api-Key: test-token, got %s", r.Header.Get("X-Api-Key")) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(mockResponse) + })) + defer server.Close() + + client := httputil.NewClient(5 * time.Second) + inst := config.ArrInstance{Name: "Test", URL: server.URL, APIKey: "test-token"} + ctx := context.Background() + + series, err := fetchSonarrLibrary(ctx, client, inst, false) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if len(series) != 1 { + t.Fatalf("Got %d series, want 1", len(series)) + } + if series[0].Title != "Test Show" { + t.Errorf("Title = %q, want %q", series[0].Title, "Test Show") + } + if !series[0].Monitored { + t.Error("Expected Monitored = true") + } +} + +func TestFetchRadarrLibrary(t *testing.T) { + mockResponse := []RadarrMovie{ + { + ID: 1, + Title: "Test Movie", + Year: 2024, + TMDBID: 67890, + HasFile: true, + Status: "released", + Monitored: true, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Errorf("Expected GET, got %s", r.Method) + } + if r.Header.Get("X-Api-Key") != "test-token" { + t.Errorf("Expected X-Api-Key: test-token, got %s", r.Header.Get("X-Api-Key")) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(mockResponse) + })) + defer server.Close() + + client := httputil.NewClient(5 * time.Second) + inst := config.ArrInstance{Name: "Test", URL: server.URL, APIKey: "test-token"} + ctx := context.Background() + + movies, err := fetchRadarrLibrary(ctx, client, inst, false) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if len(movies) != 1 { + t.Fatalf("Got %d movies, want 1", len(movies)) + } + if movies[0].Title != "Test Movie" { + t.Errorf("Title = %q, want %q", movies[0].Title, "Test Movie") + } + if !movies[0].HasFile { + t.Error("Expected HasFile = true") + } +} + +func TestFetchSonarrEpisodes(t *testing.T) { + now := time.Now() + mockResponse := []SonarrEpisode{ + { + ID: 1, + Title: "Pilot", + AirDateUtc: now.AddDate(0, 0, -1), + SeasonNumber: 1, + EpisodeNumber: 1, + HasFile: true, + }, + { + ID: 2, + Title: "Second", + AirDateUtc: now.AddDate(0, 0, 7), + SeasonNumber: 1, + EpisodeNumber: 2, + HasFile: false, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(mockResponse) + })) + defer server.Close() + + client := httputil.NewClient(5 * time.Second) + inst := config.ArrInstance{Name: "Test", URL: server.URL, APIKey: "test-token"} + ctx := context.Background() + + episodes, err := fetchSonarrEpisodes(ctx, client, inst, 1, false) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if len(episodes) != 2 { + t.Fatalf("Got %d episodes, want 2", len(episodes)) + } +} + +func TestGroupEpisodesBySeason(t *testing.T) { + eps := []SonarrEpisode{ + {SeasonNumber: 1, EpisodeNumber: 1}, + {SeasonNumber: 1, EpisodeNumber: 2}, + {SeasonNumber: 2, EpisodeNumber: 3}, + } + m := groupEpisodesBySeason(eps) + if len(m) != 2 { + t.Errorf("got %d seasons, want 2", len(m)) + } + if len(m[1]) != 2 { + t.Errorf("season 1 has %d episodes, want 2", len(m[1])) + } + if len(m[2]) != 1 { + t.Errorf("season 2 has %d episodes, want 1", len(m[2])) + } +} + +func TestCountOnDisk(t *testing.T) { + eps := []SonarrEpisode{ + {HasFile: true}, + {HasFile: false}, + {HasFile: true}, + } + if n := countOnDisk(eps); n != 2 { + t.Errorf("got %d, want 2", n) + } +} + +func TestResolveMovieStatus(t *testing.T) { + tests := []struct { + status string + hasFile bool + want string + }{ + {"released", true, "released"}, + {"inCinemas", false, "announced"}, + {"announced", false, "announced"}, + } + for _, tc := range tests { + cfg := ToolConfig{} + c := SeriesOrMovie{Status: tc.status, HasFile: tc.hasFile} + got := resolveMovieStatus(c, cfg) + if got != tc.want { + t.Errorf("resolveMovieStatus(%q, %v) = %q, want %q", tc.status, tc.hasFile, got, tc.want) + } + } +} + +func TestParseDateFlexible(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"2024-12-25T21:00:00Z", true}, + {"2024-12-25", true}, + {"not-a-date", false}, + } + for _, tc := range tests { + _, err := parseDateFlexible(tc.input) + got := err == nil + if got != tc.want { + t.Errorf("parseDateFlexible(%q) ok=%v, want %v", tc.input, got, tc.want) + } + } +} + +func TestFindCurrentSeason(t *testing.T) { + now := time.Now() + seasons := episodeBySeason{ + 1: { + {AirDateUtc: now.AddDate(0, -6, 0)}, // 6 months ago + {AirDateUtc: now.AddDate(0, -6, 1)}, + }, + 2: { + {AirDateUtc: now.AddDate(0, 0, -3)}, // 3 days ago + {AirDateUtc: now.AddDate(0, 0, 10)}, // 10 days from now + }, + } + sn := findCurrentSeason(seasons) + if sn != 2 { + t.Errorf("expected season 2 (closest to now), got %d", sn) + } +} + +func TestGetMovieReleaseTime(t *testing.T) { + m := RadarrMovie{ + DigitalRelease: "2024-12-25", + PhysicalRelease: "2025-01-15", + InCinemas: "2024-12-20", + } + rt := getMovieReleaseTime(m) + if rt.IsZero() { + t.Fatal("expected non-zero release time") + } + // DigitalRelease should be preferred + expected, _ := parseDateFlexible("2024-12-25") + if !rt.Equal(expected) { + t.Errorf("expected %v, got %v", expected, rt) + } +} + +func TestGetMovieReleaseTimeFallback(t *testing.T) { + m := RadarrMovie{ + InCinemas: "2024-12-20", + } + rt := getMovieReleaseTime(m) + if rt.IsZero() { + t.Fatal("expected non-zero release time from inCinemas fallback") + } +} + +func TestTokenise(t *testing.T) { + tokens := tokenise("Clarkson's Farm 2021") + expected := []string{"clarkson", "s", "farm", "2021"} + if len(tokens) != len(expected) { + t.Fatalf("got %d tokens, want %d: %v", len(tokens), len(expected), tokens) + } + for i := range expected { + if tokens[i] != expected[i] { + t.Errorf("token[%d] = %q, want %q", i, tokens[i], expected[i]) + } + } +} + +func TestFormatRelativeDate(t *testing.T) { + now := time.Date(2024, 12, 25, 12, 0, 0, 0, time.UTC) + tests := []struct { + t time.Time + want string + }{ + {now, "now"}, + {now.Add(-30 * time.Second), "now"}, + {now.Add(-2 * time.Minute), "2 minutes ago"}, + {now.Add(-1 * time.Hour), "1 hour ago"}, + {now.Add(-3 * 24 * time.Hour), "3 days ago"}, + {now.Add(-1 * 24 * time.Hour), "yesterday"}, + {now.Add(2 * time.Minute), "in 2 minutes"}, + {now.Add(1 * time.Hour), "in 1 hour"}, + {now.Add(3 * 24 * time.Hour), "in 3 days"}, + {now.Add(1 * 24 * time.Hour), "tomorrow"}, + } + for _, tc := range tests { + got := formatRelativeDate(now, tc.t) + if got != tc.want { + t.Errorf("formatRelativeDate(%v) = %q, want %q", tc.t, got, tc.want) + } + } +} diff --git a/internal/airtime/render.go b/internal/airtime/render.go new file mode 100644 index 0000000..4d7cd9b --- /dev/null +++ b/internal/airtime/render.go @@ -0,0 +1,359 @@ +package airtime + +import ( + "bufio" + "bytes" + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/calmcacil/CalmsToolkit/internal/colors" + "golang.org/x/term" +) + +func interactiveSelect(ctx context.Context, candidates []scoredMatch, query string, cfg ToolConfig) *scoredMatch { + if !term.IsTerminal(int(os.Stdin.Fd())) { + fmt.Fprintf(os.Stderr, "Multiple matches (use -json or pipe through TTY for interactive pick):\n") + for i, sm := range candidates { + icon := iconForType(sm.Candidate.Type) + fmt.Fprintf(os.Stderr, " %d. %s %s (%d) [%s]\n", i+1, icon, sm.Candidate.Title, sm.Candidate.Year, sm.Candidate.Source) + } + return &candidates[0] + } + + p := colors.GetPalette(cfg.Theme) + clr := colors.ClrFunc(cfg.NoColor) + termWidth := terminalWidth() + if termWidth < 40 { + termWidth = 40 + } + + reader := bufio.NewReader(os.Stdin) + for { + var buf bytes.Buffer + bw := bufio.NewWriter(&buf) + + title := fmt.Sprintf("candidates for %q", query) + boxW := termWidth - 2 + if boxW > 80 { + boxW = 80 + } + + fmt.Fprint(bw, "┌── ") + fmt.Fprint(bw, clr(p.Accent)) + fmt.Fprint(bw, title) + fmt.Fprint(bw, clr(p.Reset)) + rem := boxW - len(title) - 4 + if rem < 0 { + rem = 0 + } + fmt.Fprint(bw, strings.Repeat(" ", rem)) + fmt.Fprint(bw, "┐\n") + + for i, sm := range candidates { + icon := iconForType(sm.Candidate.Type) + label := fmt.Sprintf(" %d. %s %s (%d)", i+1, icon, sm.Candidate.Title, sm.Candidate.Year) + meta := fmt.Sprintf("[%s]", sm.Candidate.Source) + if sm.Candidate.Monitored && sm.Candidate.HasFile { + meta += " [Has]" + } else if sm.Candidate.Monitored { + meta += " [Mon]" + } + fmt.Fprint(bw, "│") + fmt.Fprint(bw, label) + remaining := boxW - len(label) - len(meta) - 1 + if remaining < 1 { + remaining = 1 + } + fmt.Fprint(bw, strings.Repeat(" ", remaining)) + fmt.Fprint(bw, clr(p.Subdued)) + fmt.Fprint(bw, meta) + fmt.Fprint(bw, clr(p.Reset)) + fmt.Fprint(bw, "│\n") + } + + fmt.Fprint(bw, "├") + fmt.Fprint(bw, strings.Repeat("─", boxW)) + fmt.Fprint(bw, "┤\n") + + help := "Select [1..N] (s=search again, q=quit): " + fmt.Fprint(bw, "│ ") + fmt.Fprint(bw, help) + pad := boxW - len(help) - 1 + if pad > 0 { + fmt.Fprint(bw, strings.Repeat(" ", pad)) + } + fmt.Fprint(bw, "│\n") + + fmt.Fprint(bw, "└") + fmt.Fprint(bw, strings.Repeat("─", boxW)) + fmt.Fprint(bw, "┘\n") + + bw.Flush() + fmt.Fprint(os.Stdout, "\033[s") // save cursor + os.Stdout.Write(buf.Bytes()) + + line, err := reader.ReadString('\n') + if err != nil { + return &candidates[0] + } + line = strings.TrimSpace(strings.ToLower(line)) + + fmt.Fprint(os.Stdout, "\033[u\033[J") // restore + clear + + if line == "q" { + fmt.Fprintln(os.Stderr, "Quit.") + return nil + } + if line == "s" { + fmt.Fprintf(os.Stderr, "Start a new search with: media-airtime \n") + return nil + } + + var idx int + if _, err := fmt.Sscanf(line, "%d", &idx); err == nil && idx >= 1 && idx <= len(candidates) { + return &candidates[idx-1] + } + } +} + +func renderCard(info AirtimeInfo, cfg ToolConfig) { + p := colors.GetPalette(cfg.Theme) + clr := colors.ClrFunc(cfg.NoColor) + termWidth := terminalWidth() + if termWidth < 40 { + termWidth = 40 + } + + boxW := termWidth - 2 + if boxW > 100 { + boxW = 100 + } + + icon := iconForType(info.Type) + title := fmt.Sprintf(" %s %s (%d) ─ %s ", icon, info.Title, info.Year, info.Source) + topRem := boxW - len(title) + if topRem < 1 { + topRem = 1 + } + + var bodyBuf bytes.Buffer + bw := bufio.NewWriter(&bodyBuf) + + fmt.Fprint(bw, "┌──") + fmt.Fprint(bw, title) + fmt.Fprint(bw, strings.Repeat("─", topRem)) + fmt.Fprint(bw, "┐\n") + + now := time.Now() + + addLine := func(key, val string) { + line := fmt.Sprintf("│ %s%s%s %s%s", clr(p.Bold), key, clr(p.Reset), val, clr(p.Reset)) + rem := boxW - len("│ ") - len(key) - len(" ") - colors.VisibleLen(val) + if rem < 1 { + rem = 1 + } + line += strings.Repeat(" ", rem-1) + line += "│\n" + fmt.Fprint(bw, line) + } + + addSep := func() { + fmt.Fprint(bw, "│") + fmt.Fprint(bw, strings.Repeat(" ", boxW-1)) + fmt.Fprint(bw, "│\n") + } + + if info.Type == "series" { + statusColor := statusColor(info.Status, p) + addLine("Status:", fmt.Sprintf("%s%s%s", clr(statusColor), info.Status, clr(p.Reset))) + + libStatus := "not monitored" + if info.EpisodesTotal > 0 { + diskLabel := "missing" + if info.EpisodesOnDisk == info.EpisodesTotal { + diskLabel = "on disk" + } + libStatus = fmt.Sprintf("monitored, %d/%d %s", info.EpisodesOnDisk, info.EpisodesTotal, diskLabel) + } else if info.Monitored { + libStatus = "monitored" + } + addLine("Library:", libStatus) + + if info.TVDBID > 0 { + addLine("TVDB:", fmt.Sprintf("%d", info.TVDBID)) + } + addSep() + + seasonLabel := "Current season:" + if info.Status == "ended" { + seasonLabel = "Final season:" + } + addLine(seasonLabel, fmt.Sprintf("%d (%d episodes)", info.Season, info.EpisodesTotal)) + + if info.LastAir != nil { + rel := formatRelativeDate(now, *info.LastAir) + lastStr := fmt.Sprintf("%s", info.LastLabel) + if len(lastStr) > boxW-20 { + lastStr = truncate(lastStr, boxW-23) + } + addLine(" Last air:", fmt.Sprintf("%s", lastStr)) + addLine(" ", rel) + } + + if info.NextAir != nil { + rel := formatRelativeDate(now, *info.NextAir) + nextStr := fmt.Sprintf("%s", info.NextLabel) + if len(nextStr) > boxW-20 { + nextStr = truncate(nextStr, boxW-23) + } + addLine(" Next air:", fmt.Sprintf("%s", nextStr)) + addLine(" ", rel) + } else if info.Status == "ended" { + addLine(" Next air:", clr(p.Subdued)+"— (series has ended)"+clr(p.Reset)) + } else { + addLine(" Next air:", clr(p.Subdued)+"— (TBA)"+clr(p.Reset)) + } + } else { + statusColor := statusColor(info.Status, p) + addLine("Status:", fmt.Sprintf("%s%s%s", clr(statusColor), info.Status, clr(p.Reset))) + + libStatus := "not monitored" + if info.Monitored { + libStatus = "monitored" + } + if info.EpisodesTotal > 0 { + libStatus = fmt.Sprintf("monitored, %d/%d on disk", info.EpisodesOnDisk, info.EpisodesTotal) + } + addLine("Library:", libStatus) + + if info.TMDBID > 0 { + addLine("TMDB:", fmt.Sprintf("%d", info.TMDBID)) + } + addSep() + + if info.LastAir != nil { + rel := formatRelativeDate(now, *info.LastAir) + addLine(" Release:", rel) + } else if info.NextAir != nil { + rel := formatRelativeDate(now, *info.NextAir) + addLine(" Release:", rel) + } else { + addLine(" Release:", clr(p.Subdued)+"— (TBA)"+clr(p.Reset)) + } + } + + fmt.Fprint(bw, "└") + fmt.Fprint(bw, strings.Repeat("─", boxW)) + fmt.Fprint(bw, "┘\n") + + bw.Flush() + os.Stdout.Write(bodyBuf.Bytes()) +} + +func iconForType(t string) string { + switch t { + case "series": + return "[TV]" + case "movie": + return "[Film]" + default: + return "[?]" + } +} + +func statusColor(status string, p *colors.Palette) string { + switch status { + case "ongoing": + return p.Success + case "released": + return p.Success + case "ended": + return p.Subdued + case "announced": + return p.Info + default: + return p.Warning + } +} + +func formatRelativeDate(now, t time.Time) string { + diff := t.Sub(now) + + if diff >= -30*time.Second && diff <= 30*time.Second { + return "now" + } + + if diff > 0 { + if diff < time.Minute { + return "in a few seconds" + } + if diff < time.Hour { + m := int(diff.Minutes()) + if m == 1 { + return "in 1 minute" + } + return fmt.Sprintf("in %d minutes", m) + } + if diff < 24*time.Hour { + h := int(diff.Hours()) + if h == 1 { + return "in 1 hour" + } + return fmt.Sprintf("in %d hours", h) + } + d := int(diff.Hours() / 24) + if d == 1 { + return "tomorrow" + } + return fmt.Sprintf("in %d days", d) + } + + diff = -diff + if diff < time.Minute { + return "just now" + } + if diff < time.Hour { + m := int(diff.Minutes()) + if m == 1 { + return "1 minute ago" + } + return fmt.Sprintf("%d minutes ago", m) + } + if diff < 24*time.Hour { + h := int(diff.Hours()) + if h == 1 { + return "1 hour ago" + } + return fmt.Sprintf("%d hours ago", h) + } + if diff < 7*24*time.Hour { + d := int(diff.Hours() / 24) + if d == 1 { + return "yesterday" + } + return fmt.Sprintf("%d days ago", d) + } + + return t.Local().Format("2006-01-02 15:04") +} + +func terminalWidth() int { + w, _, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil || w <= 0 { + return 80 + } + return w +} + +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + if maxLen <= 3 { + return s[:maxLen] + } + return s[:maxLen-3] + "..." +} diff --git a/internal/airtime/score.go b/internal/airtime/score.go new file mode 100644 index 0000000..95f5fc6 --- /dev/null +++ b/internal/airtime/score.go @@ -0,0 +1,133 @@ +package airtime + +import ( + "regexp" + "strings" + "unicode" +) + +var yearRe = regexp.MustCompile(`\b\d{4}\b`) + +type scoredMatch struct { + Candidate SeriesOrMovie + Score int +} + +func tokenise(s string) []string { + s = strings.ToLower(s) + var tokens []string + var cur strings.Builder + for _, r := range s { + if r == '\'' || unicode.IsSpace(r) { + if cur.Len() > 0 { + tokens = append(tokens, cur.String()) + cur.Reset() + } + continue + } + cur.WriteRune(r) + } + if cur.Len() > 0 { + tokens = append(tokens, cur.String()) + } + return tokens +} + +func scoreCandidate(query string, c SeriesOrMovie) int { + qTokens := tokenise(query) + titleTokens := tokenise(c.Title) + + if len(qTokens) == 0 { + return 0 + } + + var score int + + inLibrary := c.Monitored || c.HasFile + + yearStr := "" + if c.Year > 0 { + yearStr = itoa(c.Year) + } + + isYearToken := func(s string) bool { return len(s) == 4 && yearRe.MatchString(s) } + + for _, qt := range qTokens { + if isYearToken(qt) { + continue + } + found := false + for _, tt := range titleTokens { + if strings.Contains(tt, qt) { + found = true + break + } + } + if !found { + return 0 + } + } + + score += 50 + + prefixCount := 0 + for _, qt := range qTokens { + for _, tt := range titleTokens { + if strings.HasPrefix(tt, qt) { + prefixCount++ + break + } + } + } + score += min(prefixCount, 4) * 20 + + qNoApos := strings.ReplaceAll(strings.ToLower(query), "'", "") + tNoApos := strings.ReplaceAll(strings.ToLower(c.Title), "'", "") + ti := 0 + for _, r := range qNoApos { + pos := strings.IndexRune(tNoApos[ti:], r) + if pos < 0 { + break + } + ti += pos + 1 + } + if ti > 0 && ti == len(qNoApos) { + score += 20 + } + + if inLibrary { + score += 30 + } + if c.HasFile { + score += 10 + } + + qYear := yearRe.FindString(query) + if qYear != "" && yearStr != "" && qYear == yearStr { + score += 25 + } + + return score +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var buf [12]byte + i := len(buf) + neg := n < 0 + if neg { + n = -n + } + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} diff --git a/internal/config/config.go b/internal/config/config.go index 58c1041..e3f8317 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,6 +26,7 @@ type ToolkitConfig struct { MediaCalendar CalendarConfig `json:"media_calendar"` MediaStreams StreamsConfig `json:"media_streams"` MediaRequests RequestsConfig `json:"media_requests"` + MediaAirtime AirtimeConfig `json:"media_airtime"` ArrFeed FeedConfig `json:"arr_feed"` } @@ -69,6 +70,14 @@ type RequestsConfig struct { Verbose bool `json:"verbose"` } +// AirtimeConfig holds media airtime search tool settings. +type AirtimeConfig struct { + Limit int `json:"limit"` + PastDays int `json:"past_days"` + FutureDays int `json:"future_days"` + Debug bool `json:"debug"` +} + // FeedConfig holds Arr event feed tool settings. type FeedConfig struct { PollInterval string `json:"poll_interval"` @@ -120,6 +129,12 @@ func DefaultToolkitConfig() *ToolkitConfig { APIKey: "", Verbose: false, }, + MediaAirtime: AirtimeConfig{ + Limit: 10, + PastDays: 7, + FutureDays: 30, + Debug: false, + }, ArrFeed: FeedConfig{ PollInterval: "5s", HistoryWindow: "1h", @@ -229,6 +244,16 @@ func (c *ToolkitConfig) Validate() error { return fmt.Errorf("media_streams.watch_interval: must be >= 1") } + if c.MediaAirtime.Limit < 1 || c.MediaAirtime.Limit > 50 { + return fmt.Errorf("media_airtime.limit: must be between 1 and 50") + } + if c.MediaAirtime.PastDays < 0 { + return fmt.Errorf("media_airtime.past_days: must be >= 0") + } + if c.MediaAirtime.FutureDays < 0 { + return fmt.Errorf("media_airtime.future_days: must be >= 0") + } + serverType := c.MediaStreams.ServerType if serverType != "" && serverType != "plex" && serverType != "jellyfin" && serverType != "both" { return fmt.Errorf("media_streams.server_type: must be 'plex', 'jellyfin', or 'both'")