Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand 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; \
Expand All @@ -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!"

Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion cmd/calmstoolkit-setup/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
81 changes: 81 additions & 0 deletions cmd/media-airtime/main.go
Original file line number Diff line number Diff line change
@@ -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 <query> [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)
}
22 changes: 22 additions & 0 deletions cmd/media-airtime/main_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading
Loading