diff --git a/README.md b/README.md index a8f8a96..3f7076f 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ A terminal UI to search for movies and series with [Radarr](https://radarr.video - **Movies / Series**: search as you type, add titles with a quality profile and root folder, optionally start the automatic search. - **Interactive search**: list the releases Radarr/Sonarr find (approved or rejected, with reasons), - sort and filter them (by approval, quality, x265 / x264 / AV1, HDR, Dolby Vision, language), and grab one. Series: pick a season or a single episode first. -- **Library**: browse movies (top) and series (below) with a details pane on the right, filter by status (missing / downloaded / monitored) and by file properties (quality, x265 / x264 / AV1, HDR, Dolby Vision, language), toggle + sort and filter them (by approval, quality, x265 / x264 / AV1, HDR, Dolby Vision, audio format, language), and grab one. Series: pick a season or a single episode first. +- **Library**: browse movies (top) and series (below) with a details pane on the right, filter by status (missing / downloaded / monitored) and by file properties (quality, x265 / x264 / AV1, HDR, Dolby Vision, audio format, language), toggle monitoring, trigger searches. Series open into seasons and episodes with per-season and per-episode monitoring, automatic search and release search. - **Downloads**: SABnzbd queue and history plus the Radarr/Sonarr queues. Pause/resume, delete, diff --git a/SPECS.md b/SPECS.md index 2977fed..61aca34 100644 --- a/SPECS.md +++ b/SPECS.md @@ -57,7 +57,7 @@ Notes: - **Config:** load URLs + API keys from a config file; fail with a clear message if missing or unreachable. - **Search:** a Movies view (Radarr) and a Series view (Sonarr), each with a search box. Results list with title, year, rating, status, and an "in library" marker. Detail pane with overview, runtime, genres, network/studio. - **Add:** from a result, open a small form: quality profile, root folder, monitored (yes/no), "search now" (yes/no); for series also monitor mode (all / future / first season / none). Submit calls `POST /movie` or `POST /series` with `addOptions.searchForMovie` / `searchForMissingEpisodes`. -- **Interactive release search:** for a movie (or a series' season/episode) in the library, list releases from indexers (`GET /release`) with title, indexer, size, quality, age, and rejection reasons; sort/filter; grab a chosen one (`POST /release`). Rejected releases can be grabbed only after an explicit confirmation. The list can be filtered by quality, codec (x265/x264/AV1), HDR/DV/SDR and language; releases carry no media info, so codec and HDR are read from the title, quality and custom formats (a release that does not name its codec never matches a codec filter). +- **Interactive release search:** for a movie (or a series' season/episode) in the library, list releases from indexers (`GET /release`) with title, indexer, size, quality, age, and rejection reasons; sort/filter; grab a chosen one (`POST /release`). Rejected releases can be grabbed only after an explicit confirmation. The list can be filtered by quality, codec (x265/x264/AV1), HDR/DV/SDR, audio format and language; releases carry no media info, so codec and HDR are read from the title, quality and custom formats (a release that does not name its codec never matches a codec filter). - **Downloads view:** all downloads and queues in one place, auto-refreshing every few seconds: - SABnzbd queue (name, size, progress, ETA, speed) and history (completed / failed, with retry). - Radarr and Sonarr queues (status, warnings, import errors), so stuck imports are visible. @@ -67,7 +67,7 @@ Notes: ### 4.2 v0.2 (done, M8) -- **Library view:** movies (top) and series (below) with the details of the selected item on the right; Tab switches between the two lists. Filters (all, missing, downloaded, monitored, unmonitored), a file-property filter (`F`: quality 2160p/1080p/720p/SD, codec x265/x264/AV1, HDR/DV/SDR, audio language) and a text filter apply to both lists. Movie properties come from the movie list; series properties are read per series from `GET /episodefile?seriesId=` only while a property filter is active, cached while a series' file count and size stay the same, and a series matches when any of its files does. `t` toggles monitoring, `s` starts an automatic search, `i`/Enter opens the release search (movies) or the drill-down (series). +- **Library view:** movies (top) and series (below) with the details of the selected item on the right; Tab switches between the two lists. Filters (all, missing, downloaded, monitored, unmonitored), a file-property filter (`F`: quality 2160p/1080p/720p/SD, codec x265/x264/AV1, HDR/DV/SDR, audio format Atmos/TrueHD/DTS:X/DTS-HD/DTS/DD+/DD/AAC/FLAC/Opus, audio language; changes apply live to the list behind the dialog and Esc restores the previous filter) and a text filter apply to both lists. Movie properties come from the movie list; series properties are read per series from `GET /episodefile?seriesId=` only while a property filter is active, cached while a series' file count and size stay the same, and a series matches when any of its files does. `t` toggles monitoring, `s` starts an automatic search, `i`/Enter opens the release search (movies) or the drill-down (series). - **Series drill-down:** seasons and episodes, toggle monitoring per season/episode, trigger season/episode search and release search. ### 4.3 Later / ideas @@ -113,7 +113,7 @@ Keybindings (the status bar shows the ones that apply to the current view): | `/` | Movies, Series, Library: focus the search / text filter | | `Enter` / `a` | Movies, Series: add the selected result (dialog: ↑↓ move, ←→ change dropdowns) | | `i` | Movies, Series, Library: interactive release search (series: seasons and episodes) | -| `F` | Library and release list: filter by properties (quality, codec, HDR/DV, language) | +| `F` | Library and release list: filter by properties (quality, codec, HDR/DV, audio, language) | | `f` | Library: cycle the status filter; release list: hide rejected releases | | `t` | Library, episodes: toggle monitored; Downloads history: retry a failed download | | `s` | Library, episodes: automatic search; release list: cycle sort (best / size / age) | diff --git a/internal/arr/mediafile.go b/internal/arr/mediafile.go index a180de4..9f6ead9 100644 --- a/internal/arr/mediafile.go +++ b/internal/arr/mediafile.go @@ -21,6 +21,8 @@ type MediaFile struct { VideoCodec string `json:"videoCodec"` VideoDynamicRange string `json:"videoDynamicRange"` VideoDynamicRangeType string `json:"videoDynamicRangeType"` + AudioCodec string `json:"audioCodec"` + AudioAdditional string `json:"audioAdditionalFeatures"` } `json:"mediaInfo"` Languages []struct { Name string `json:"name"` @@ -40,16 +42,43 @@ type FileAttrs struct { Codec string // CodecX265, CodecX264, CodecAV1 or "" HDR bool // HDR10, HDR10+, HLG, PQ (also with Dolby Vision on top) DV bool // Dolby Vision + Audio []string // audio formats, see the Audio* constants Languages []string // audio languages } +// Audio formats offered as filters. Atmos and the DTS flavours overlap with +// their carrier format: a "TrueHD Atmos" track is both Atmos and TrueHD, and +// every DTS flavour also counts as plain DTS. +const ( + AudioAtmos = "Atmos" + AudioTrueHD = "TrueHD" + AudioDTSX = "DTS:X" + AudioDTSHD = "DTS-HD" + AudioDTS = "DTS" + AudioDDPlus = "DD+" + AudioDD = "DD" + AudioAAC = "AAC" + AudioFLAC = "FLAC" + AudioOpus = "Opus" +) + var ( - reRes = regexp.MustCompile(`(?i)\b(2160|1080|720|576|480)p\b`) - re265 = regexp.MustCompile(`(?i)(x\.?265|h\.?265|hevc)`) - re264 = regexp.MustCompile(`(?i)(x\.?264|h\.?264|\bavc\b)`) - reAV1 = regexp.MustCompile(`(?i)\bav1\b`) - reDV = regexp.MustCompile(`(?i)(\bdv\b|dovi|dolby[ .]?vision)`) - reHDR = regexp.MustCompile(`(?i)\b(hdr(10(\+|plus)?)?|hlg|pq)\b`) + reRes = regexp.MustCompile(`(?i)\b(2160|1080|720|576|480)p\b`) + re265 = regexp.MustCompile(`(?i)(x\.?265|h\.?265|hevc)`) + re264 = regexp.MustCompile(`(?i)(x\.?264|h\.?264|\bavc\b)`) + reAV1 = regexp.MustCompile(`(?i)\bav1\b`) + reDV = regexp.MustCompile(`(?i)(\bdv\b|dovi|dolby[ .]?vision)`) + reAtmos = regexp.MustCompile(`(?i)(atmos|\bddpa\b)`) + reTrueHD = regexp.MustCompile(`(?i)true[ .-]?hd`) + reDTSX = regexp.MustCompile(`(?i)\bdts[ .:-]?x\b`) + reDTSHD = regexp.MustCompile(`(?i)dts[ .-]?(hd|ma|hra|es)`) + reDTS = regexp.MustCompile(`(?i)\bdts([^a-z]|$)`) + reDDPlus = regexp.MustCompile(`(?i)(e[ .-]?ac[ .-]?3|\bddp|dd\+|dolby digital plus)`) + reDD = regexp.MustCompile(`(?i)(\bac[ .-]?3\b|\bdd[ .]?[0-9]|dolby digital\b)`) + reAAC = regexp.MustCompile(`(?i)\baac`) + reFLAC = regexp.MustCompile(`(?i)\bflac\b`) + reOpus = regexp.MustCompile(`(?i)\bopus\b`) + reHDR = regexp.MustCompile(`(?i)\b(hdr(10(\+|plus)?)?|hlg|pq)\b`) ) // Attrs derives the filterable properties. Radarr/Sonarr's media info is used @@ -77,6 +106,11 @@ func (f MediaFile) Attrs() FileAttrs { } a.DV = reDV.MatchString(dr) a.HDR = reHDR.MatchString(dr) + audio := f.MediaInfo.AudioCodec + " " + f.MediaInfo.AudioAdditional + if strings.TrimSpace(audio) == "" { + audio = names + } + a.Audio = audioTags(audio) for _, l := range f.Languages { if l.Name != "" { a.Languages = append(a.Languages, l.Name) @@ -85,6 +119,37 @@ func (f MediaFile) Attrs() FileAttrs { return a } +// audioTags finds the audio formats we offer as filters in free text. +func audioTags(s string) []string { + var tags []string + add := func(re *regexp.Regexp, tag string) { + if re.MatchString(s) { + tags = append(tags, tag) + } + } + add(reAtmos, AudioAtmos) + add(reTrueHD, AudioTrueHD) + dtsx, dtshd := reDTSX.MatchString(s), reDTSHD.MatchString(s) + if dtsx { + tags = append(tags, AudioDTSX) + } + if dtshd { + tags = append(tags, AudioDTSHD) + } + if dtsx || dtshd || reDTS.MatchString(s) { + tags = append(tags, AudioDTS) + } + if reDDPlus.MatchString(s) { + tags = append(tags, AudioDDPlus) + } else { + add(reDD, AudioDD) + } + add(reAAC, AudioAAC) + add(reFLAC, AudioFLAC) + add(reOpus, AudioOpus) + return tags +} + func atoi(s string) (n int) { for _, c := range s { n = n*10 + int(c-'0') diff --git a/internal/arr/mediafile_test.go b/internal/arr/mediafile_test.go index 4250f2f..05b8db6 100644 --- a/internal/arr/mediafile_test.go +++ b/internal/arr/mediafile_test.go @@ -71,3 +71,44 @@ func TestReleaseAttrs(t *testing.T) { t.Errorf("HDTV detected as HDR: %+v", got) } } + +func TestAudioTags(t *testing.T) { + for _, tc := range []struct { + in string + want []string + }{ + {"Movie.2160p.UHD.BluRay.TrueHD.7.1.Atmos.x265-G", []string{"Atmos", "TrueHD"}}, + {"Movie.1080p.DDP5.1.Atmos.x265", []string{"Atmos", "DD+"}}, + {"Movie.1080p.DTS-HD.MA.5.1.x264", []string{"DTS-HD", "DTS"}}, + {"Movie.2160p.DTS-X.7.1", []string{"DTS:X", "DTS"}}, + {"Movie.1080p.DTS.5.1", []string{"DTS"}}, + {"Movie.1080p.EAC3.5.1", []string{"DD+"}}, + {"Movie.1080p.E-AC-3.5.1", []string{"DD+"}}, + {"Movie.1080p.AC3.5.1", []string{"DD"}}, + {"Movie.720p.DD5.1.x264", []string{"DD"}}, + {"Show.S01E01.AAC.2.0", []string{"AAC"}}, + {"Album.FLAC", []string{"FLAC"}}, + {"Show.Opus", []string{"Opus"}}, + {"Movie.1080p.BluRay.x264-DTSAG", nil}, // a group name is not an audio format + {"Movie.1080p.x264", nil}, + } { + if got := audioTags(tc.in); !reflect.DeepEqual(got, tc.want) { + t.Errorf("audioTags(%q) = %v, want %v", tc.in, got, tc.want) + } + } +} + +func TestMediaFileAudio(t *testing.T) { + a := attrs(t, `{"sceneName":"Movie.x264.AC3","mediaInfo":{"audioCodec":"TrueHD Atmos"}}`) + if !reflect.DeepEqual(a.Audio, []string{"Atmos", "TrueHD"}) { + t.Errorf("media info wins over the name: %v", a.Audio) + } + a = attrs(t, `{"mediaInfo":{"audioCodec":"EAC3","audioAdditionalFeatures":"Atmos"}}`) + if !reflect.DeepEqual(a.Audio, []string{"Atmos", "DD+"}) { + t.Errorf("additional features: %v", a.Audio) + } + a = attrs(t, `{"relativePath":"Movie.1080p.DTS-HD.MA.mkv"}`) + if !reflect.DeepEqual(a.Audio, []string{"DTS-HD", "DTS"}) { + t.Errorf("fallback to the name: %v", a.Audio) + } +} diff --git a/internal/arr/release.go b/internal/arr/release.go index 5a2b0fd..e01ac81 100644 --- a/internal/arr/release.go +++ b/internal/arr/release.go @@ -87,6 +87,7 @@ func (r Release) Attrs() FileAttrs { } a.DV = reDV.MatchString(text) a.HDR = reHDR.MatchString(text) + a.Audio = audioTags(text) for _, l := range r.Languages { if l.Name != "" { a.Languages = append(a.Languages, l.Name) diff --git a/internal/ui/app.go b/internal/ui/app.go index 847cd16..7434e46 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -207,6 +207,17 @@ func (a *App) openModal(p tview.Primitive, width, height int) { a.pushOverlay(box, p, "↑↓ move · ←→ change · Enter select · Esc cancel", false, nil) } +// openSidePanel shows p at the top right so most of the view behind it stays +// visible, for dialogs whose changes are applied live. +func (a *App) openSidePanel(p tview.Primitive, width, height int, hints string) { + box := tview.NewFlex(). + AddItem(nil, 0, 1, false). + AddItem(tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(p, height, 0, true). + AddItem(nil, 0, 1, false), width, 0, true) + a.pushOverlay(box, p, hints, false, nil) +} + // openFull shows p above the whole view area (the tab bar and status bar stay visible). func (a *App) openFull(p tview.Primitive, hints string, onClose func()) { a.pushOverlay(p, p, hints, true, onClose) diff --git a/internal/ui/help.go b/internal/ui/help.go index 058fed8..a40231e 100644 --- a/internal/ui/help.go +++ b/internal/ui/help.go @@ -23,7 +23,9 @@ const helpText = `[yellow]Global[-] f cycle filter (all, missing, downloaded, monitored, unmonitored); filters apply to both lists F filter by file properties: quality, codec (x265, x264, AV1), - HDR / DV / SDR, language. Series are read on demand. + HDR / DV / SDR, audio (Atmos, TrueHD, DTS, DD+ ...), language. + Changes apply live behind the dialog; Esc undoes them. + Series are read on demand. / text filter (Enter or Esc returns to the list) t toggle monitored s automatic search (series: all missing, asks first) @@ -39,8 +41,8 @@ const helpText = `[yellow]Global[-] Enter, g grab the selected release s cycle sort (best, size, age) f hide rejected releases - F filter by quality, codec, HDR / DV, language (read from the - release title, quality and custom formats) + F filter by quality, codec, HDR / DV, audio, language (read from + the release title, quality and custom formats) [yellow]Downloads[-] p pause / resume the SABnzbd queue diff --git a/internal/ui/libfilter.go b/internal/ui/libfilter.go index d3438d1..246f03c 100644 --- a/internal/ui/libfilter.go +++ b/internal/ui/libfilter.go @@ -13,7 +13,7 @@ import ( // empty field means "any". All fields must match the same file; a series // matches when any of its files does. type attrFilter struct { - res, codec, hdr, lang string + res, codec, hdr, audio, lang string } const anyOption = "any" @@ -22,6 +22,8 @@ var ( resOptions = []string{"2160p", "1080p", "720p", "SD"} codecOptions = []string{arr.CodecX265, arr.CodecX264, arr.CodecAV1} hdrOptions = []string{"HDR", "DV", "SDR"} + audioOptions = []string{arr.AudioAtmos, arr.AudioTrueHD, arr.AudioDTSX, arr.AudioDTSHD, arr.AudioDTS, + arr.AudioDDPlus, arr.AudioDD, arr.AudioAAC, arr.AudioFLAC, arr.AudioOpus} ) func (f attrFilter) active() bool { return f != attrFilter{} } @@ -29,7 +31,7 @@ func (f attrFilter) active() bool { return f != attrFilter{} } // String lists the active criteria for the header, e.g. "1080p x265 DV". func (f attrFilter) String() string { var parts []string - for _, p := range []string{f.res, f.codec, f.hdr, f.lang} { + for _, p := range []string{f.res, f.codec, f.hdr, f.audio, f.lang} { if p != "" { parts = append(parts, p) } @@ -66,6 +68,9 @@ func (f attrFilter) matchFile(a arr.FileAttrs) bool { return false } } + if f.audio != "" && !slices.Contains(a.Audio, f.audio) { + return false + } if f.lang != "" && !slices.ContainsFunc(a.Languages, func(l string) bool { return strings.EqualFold(l, f.lang) }) { return false } @@ -104,8 +109,10 @@ func indexOf(options []string, v string) int { return 0 } -// openAttrFilter shows the dialog to pick quality, codec, HDR and language. -func (a *App) openAttrFilter(cur attrFilter, languages []string, apply func(attrFilter)) { +// openAttrFilter shows the dialog to pick quality, codec, HDR, audio and +// language. Every change is passed to set immediately so the list behind the +// dialog follows along; Cancel and Esc restore cur. +func (a *App) openAttrFilter(cur attrFilter, languages []string, set func(attrFilter)) { if cur.lang != "" && !slices.Contains(languages, cur.lang) { languages = append([]string{cur.lang}, languages...) } @@ -114,12 +121,14 @@ func (a *App) openAttrFilter(cur attrFilter, languages []string, apply func(attr lRes = "Quality" lCodec = "Codec" lHDR = "HDR" + lAudio = "Audio" lLang = "Language" ) form := tview.NewForm(). AddDropDown(lRes, withAny(resOptions), indexOf(resOptions, cur.res), nil). AddDropDown(lCodec, withAny(codecOptions), indexOf(codecOptions, cur.codec), nil). AddDropDown(lHDR, withAny(hdrOptions), indexOf(hdrOptions, cur.hdr), nil). + AddDropDown(lAudio, withAny(audioOptions), indexOf(audioOptions, cur.audio), nil). AddDropDown(lLang, withAny(languages), indexOf(languages, cur.lang), nil) pick := func(label string, options []string) string { @@ -129,22 +138,31 @@ func (a *App) openAttrFilter(cur attrFilter, languages []string, apply func(attr } return options[i-1] } - form.AddButton("Apply", func() { - a.closeModal() - apply(attrFilter{ + current := func() attrFilter { + return attrFilter{ res: pick(lRes, resOptions), codec: pick(lCodec, codecOptions), hdr: pick(lHDR, hdrOptions), + audio: pick(lAudio, audioOptions), lang: pick(lLang, languages), - }) - }) + } + } + for _, label := range []string{lRes, lCodec, lHDR, lAudio, lLang} { + form.GetFormItemByLabel(label).(*tview.DropDown).SetSelectedFunc(func(string, int) { set(current()) }) + } + + cancel := func() { + a.closeModal() + set(cur) + } + form.AddButton("Apply", a.closeModal) form.AddButton("Reset", func() { a.closeModal() - apply(attrFilter{}) + set(attrFilter{}) }) - form.AddButton("Cancel", a.closeModal) - form.SetCancelFunc(a.closeModal) + form.AddButton("Cancel", cancel) + form.SetCancelFunc(cancel) arrowNavigation(form) form.SetBorder(true).SetTitle(" Filter by properties ") - a.openModal(form, 50, 13) + a.openSidePanel(form, 40, 17, "↑↓ move · ←→ change (applies live) · Enter apply · Esc cancel") } diff --git a/internal/ui/library_test.go b/internal/ui/library_test.go index 1ca1c4d..16a5b75 100644 --- a/internal/ui/library_test.go +++ b/internal/ui/library_test.go @@ -113,6 +113,8 @@ func TestAttrFilter(t *testing.T) { {"dv", attrFilter{hdr: "DV"}, []arr.FileAttrs{hd}, false}, {"sdr", attrFilter{hdr: "SDR"}, []arr.FileAttrs{hd}, true}, {"sdr excludes hdr", attrFilter{hdr: "SDR"}, []arr.FileAttrs{uhd}, false}, + {"audio", attrFilter{audio: "Atmos"}, []arr.FileAttrs{{Audio: []string{"Atmos", "TrueHD"}}}, true}, + {"audio mismatch", attrFilter{audio: "DTS"}, []arr.FileAttrs{{Audio: []string{"DD+"}}}, false}, {"language is case-insensitive", attrFilter{lang: "german"}, []arr.FileAttrs{uhd}, true}, {"all criteria must match one file", attrFilter{res: "1080p", codec: "x265"}, []arr.FileAttrs{hd, uhd}, false}, {"any file of a series", attrFilter{res: "1080p", codec: "x264"}, []arr.FileAttrs{uhd, hd}, true},