Skip to content

Commit cd795c7

Browse files
committed
feat(navigation): enhance episode navigation logic and improve error handling
- Update GetNextEpisode and GetPreviousEpisode to validate against actual episode list. - Refactor ListAllEpisodes to return real episode numbers instead of fabricated values. - Introduce regression tests for SuperFlix and AllAnime source handling. - Implement error handling improvements in player menu actions to prevent unintended auto-advances.
1 parent f13f1b3 commit cd795c7

9 files changed

Lines changed: 353 additions & 58 deletions

File tree

internal/playback/allanime_navigation.go

Lines changed: 64 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package playback
33

44
import (
55
"fmt"
6+
"slices"
67
"strconv"
78
"strings"
89
"sync"
@@ -61,13 +62,17 @@ func (nav *AllAnimeNavigator) GetNextEpisode(currentEpisode string) (string, err
6162
return "", fmt.Errorf("invalid current episode number: %w", err)
6263
}
6364

65+
// Validate against the actual episode list instead of assuming a
66+
// contiguous 1..N numbering: lists starting at "0" (or with gaps) made
67+
// the old len()-based check accept phantom episodes past the last one.
6468
next := current + 1
65-
if next > len(nav.episodes) {
69+
target := strconv.Itoa(next)
70+
if !slices.Contains(nav.episodes, target) {
6671
return "", fmt.Errorf("no next episode available (current: %d, total: %d)", current, len(nav.episodes))
6772
}
6873

6974
util.Debugf("Navigating to next episode from=%d to=%d", current, next)
70-
return strconv.Itoa(next), nil
75+
return target, nil
7176
}
7277

7378
// GetPreviousEpisode returns the previous episode before the current one
@@ -77,13 +82,16 @@ func (nav *AllAnimeNavigator) GetPreviousEpisode(currentEpisode string) (string,
7782
return "", fmt.Errorf("invalid current episode number: %w", err)
7883
}
7984

85+
// Same list-membership check as GetNextEpisode: allows "0" when the
86+
// source really has an episode 0 and rejects anything not in the list.
8087
prev := current - 1
81-
if prev < 1 {
88+
target := strconv.Itoa(prev)
89+
if !slices.Contains(nav.episodes, target) {
8290
return "", fmt.Errorf("no previous episode available (current: %d)", current)
8391
}
8492

8593
util.Debugf("Navigating to previous episode from=%d to=%d", current, prev)
86-
return strconv.Itoa(prev), nil
94+
return target, nil
8795
}
8896

8997
// GetTotalEpisodes returns the total number of episodes
@@ -94,30 +102,51 @@ func (nav *AllAnimeNavigator) GetTotalEpisodes() int {
94102
// ListAllEpisodes returns all available episode numbers
95103
func (nav *AllAnimeNavigator) ListAllEpisodes() []string {
96104
result := make([]string, len(nav.episodes))
97-
for i := range nav.episodes {
98-
result[i] = strconv.Itoa(i + 1)
99-
}
105+
copy(result, nav.episodes)
100106
return result
101107
}
102108

103109
// Helper function to check if anime is from AllAnime source
104110
func isAllAnimeSource(anime *models.Anime) bool {
105-
if anime.Source == "AllAnime" {
106-
return true
111+
// An explicit source is authoritative. The length heuristic below used to
112+
// run even when Source was set, misrouting SuperFlix/SFlix (short numeric
113+
// TMDB IDs as URLs) into AllAnime navigation.
114+
if anime.Source != "" {
115+
return anime.Source == "AllAnime"
107116
}
108117

109118
if strings.Contains(anime.URL, "allanime") {
110119
return true
111120
}
112121

113-
// Check if URL is a short ID (AllAnime typically uses short IDs)
114-
if len(anime.URL) < 30 &&
115-
strings.ContainsAny(anime.URL, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789") &&
116-
!strings.Contains(anime.URL, "http") {
117-
return true
122+
// AllAnime IDs are short opaque alphanumeric tokens containing at least
123+
// one letter (purely numeric IDs belong to other sources).
124+
return anime.URL != "" &&
125+
len(anime.URL) < 30 &&
126+
isAlphanumericID(anime.URL) &&
127+
strings.ContainsAny(anime.URL, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")
128+
}
129+
130+
// isAlphanumericID reports whether s is non-empty and contains only ASCII
131+
// letters and digits.
132+
func isAlphanumericID(s string) bool {
133+
for _, r := range s {
134+
isAlnum := ('a' <= r && r <= 'z') || ('A' <= r && r <= 'Z') || ('0' <= r && r <= '9')
135+
if !isAlnum {
136+
return false
137+
}
118138
}
139+
return s != ""
140+
}
119141

120-
return false
142+
// allAnimePathWords are URL path segments that precede the anime ID in full
143+
// AllAnime URLs and must never be mistaken for the ID itself.
144+
var allAnimePathWords = map[string]bool{
145+
"anime": true,
146+
"bangumi": true,
147+
"watch": true,
148+
"serie": true,
149+
"series": true,
121150
}
122151

123152
// Helper function to extract AllAnime ID from URL
@@ -127,12 +156,27 @@ func extractAllAnimeID(url string) string {
127156
return url
128157
}
129158

130-
// Extract ID from full AllAnime URLs if needed
159+
// Extract the ID from full AllAnime URLs. The old scan returned the first
160+
// "long alphanumeric" segment, which for any https URL was the scheme
161+
// ("https:") — poisoning the navigator cache key and every episode fetch.
131162
if strings.Contains(url, "allanime") {
132-
parts := strings.SplitSeq(url, "/")
133-
for part := range parts {
134-
if len(part) > 5 && len(part) < 30 &&
135-
strings.ContainsAny(part, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789") {
163+
parts := strings.Split(url, "/")
164+
165+
// Prefer the segment right after a known path word (/anime/<id>/...)
166+
for i, part := range parts {
167+
if allAnimePathWords[part] && i+1 < len(parts) &&
168+
len(parts[i+1]) < 30 && isAlphanumericID(parts[i+1]) {
169+
return parts[i+1]
170+
}
171+
}
172+
173+
// Fallback: first plausible ID segment, skipping scheme and domain
174+
for _, part := range parts {
175+
if part == "" || strings.HasSuffix(part, ":") ||
176+
strings.Contains(part, ".") || allAnimePathWords[part] {
177+
continue
178+
}
179+
if len(part) > 5 && len(part) < 30 && isAlphanumericID(part) {
136180
return part
137181
}
138182
}

internal/playback/allanime_navigation_test.go

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ func TestIsAllAnimeSource(t *testing.T) {
2020
{"short id", &models.Anime{URL: "hHjXnUTda"}, true},
2121
{"http url unrelated", &models.Anime{URL: "https://animefire.io/x"}, false},
2222
{"empty", &models.Anime{}, false},
23+
// Regression: explicit non-AllAnime source must win even when the URL
24+
// is a short ID — SuperFlix/SFlix use numeric TMDB IDs as URLs and
25+
// were misrouted into AllAnime navigation.
26+
{"superflix numeric id", &models.Anime{Source: "SuperFlix", URL: "1234"}, false},
27+
{"sflix short id", &models.Anime{Source: "SFlix", URL: "abc123"}, false},
28+
{"other source with allanime-like id", &models.Anime{Source: "AnimeFire", URL: "hHjXnUTda"}, false},
29+
{"numeric-only id without source", &models.Anime{URL: "12345"}, false},
2330
}
2431
for _, tt := range tests {
2532
t.Run(tt.name, func(t *testing.T) {
@@ -37,7 +44,10 @@ func TestExtractAllAnimeID(t *testing.T) {
3744
want string
3845
}{
3946
{"short id", "hHjXnUTda", "hHjXnUTda"},
40-
{"allanime url returns first long alphanumeric segment", "https://allanime.to/anime/abc123XYZ/title", "https:"},
47+
// Regression: the old scan returned "https:" (the scheme) for any full
48+
// URL, poisoning the navigator cache key and every episode fetch.
49+
{"allanime url extracts id segment", "https://allanime.to/anime/abc123XYZ/title", "abc123XYZ"},
50+
{"allanime bangumi url extracts id segment", "https://allanime.to/bangumi/xyz789AB/some-title", "xyz789AB"},
4151
{"non-allanime", "https://example.com/x", "https://example.com/x"},
4252
}
4353
for _, tt := range tests {
@@ -86,9 +96,38 @@ func TestAllAnimeNavigator_GetTotalEpisodes(t *testing.T) {
8696

8797
func TestAllAnimeNavigator_ListAllEpisodes(t *testing.T) {
8898
t.Parallel()
89-
nav := &AllAnimeNavigator{episodes: []string{"a", "b", "c"}}
99+
// Must return the real episode numbers from the source, not fabricated
100+
// 1..N values that lie for lists starting at "0" or containing specials.
101+
nav := &AllAnimeNavigator{episodes: []string{"0", "1", "5.5"}}
90102
list := nav.ListAllEpisodes()
91-
assert.Equal(t, []string{"1", "2", "3"}, list)
103+
assert.Equal(t, []string{"0", "1", "5.5"}, list)
104+
}
105+
106+
func TestAllAnimeNavigator_GetNextEpisode_ZeroBasedList(t *testing.T) {
107+
t.Parallel()
108+
// List starts at "0": 3 entries but last real episode is "2". The old
109+
// len()-based check accepted phantom episode "3" (3 <= len(3)).
110+
nav := &AllAnimeNavigator{animeID: "x", episodes: []string{"0", "1", "2"}}
111+
112+
next, err := nav.GetNextEpisode("1")
113+
require.NoError(t, err)
114+
assert.Equal(t, "2", next)
115+
116+
_, err = nav.GetNextEpisode("2")
117+
assert.Error(t, err, "episode 3 does not exist in a 0-based list of 3 entries")
118+
}
119+
120+
func TestAllAnimeNavigator_GetPreviousEpisode_ZeroBasedList(t *testing.T) {
121+
t.Parallel()
122+
nav := &AllAnimeNavigator{animeID: "x", episodes: []string{"0", "1", "2"}}
123+
124+
// Episode "0" exists in the list, so previous from "1" must reach it.
125+
prev, err := nav.GetPreviousEpisode("1")
126+
require.NoError(t, err)
127+
assert.Equal(t, "0", prev)
128+
129+
_, err = nav.GetPreviousEpisode("0")
130+
assert.Error(t, err)
92131
}
93132

94133
func TestNewAllAnimeNavigator_RejectsNonAllAnime(t *testing.T) {

internal/playback/input.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ type menuItem struct {
1212
Value string
1313
}
1414

15+
// findMenuFunc is a package-level indirection over tui.Find so tests can
16+
// drive GetUserInput without opening a TUI.
17+
var findMenuFunc = func(items []menuItem, itemFunc func(i int) string, opts ...fuzzyfinder.Option) (int, error) {
18+
return tui.Find(items, itemFunc, opts...)
19+
}
20+
1521
// GetUserInput shows post-playback menu. Pass isMovie=true for movies to show
1622
// a simplified menu without episode navigation options.
1723
func GetUserInput(isMovie ...bool) string {
@@ -38,12 +44,15 @@ func GetUserInput(isMovie ...bool) string {
3844
}
3945
}
4046

41-
idx, err := tui.Find(items, func(i int) string {
47+
idx, err := findMenuFunc(items, func(i int) string {
4248
return items[i].Label
4349
}, fuzzyfinder.WithPromptString("What would you like to do next? "))
4450
if err != nil {
51+
// A broken or aborted menu must not auto-advance: returning "n" here
52+
// made HandleSeries/HandleMovie auto-play forever on non-TTY
53+
// terminals and turned Esc into "next episode".
4554
util.Errorf("Error showing menu: %v", err)
46-
return "n" // Default to next episode on error
55+
return "q"
4756
}
4857

4958
return items[idx].Value

internal/playback/input_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package playback
2+
3+
import (
4+
"errors"
5+
"testing"
6+
7+
"github.com/ktr0731/go-fuzzyfinder"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
// swapFindMenu replaces the menu finder seam for the duration of the test.
13+
// Tests using it mutate a package global, so they must not run in parallel.
14+
func swapFindMenu(t *testing.T, fn func(items []menuItem, itemFunc func(i int) string, opts ...fuzzyfinder.Option) (int, error)) {
15+
t.Helper()
16+
orig := findMenuFunc
17+
findMenuFunc = fn
18+
t.Cleanup(func() { findMenuFunc = orig })
19+
}
20+
21+
func TestGetUserInput_MenuErrorDoesNotAutoAdvance(t *testing.T) {
22+
// Regression: on menu failure (non-TTY, Esc, broken terminal) GetUserInput
23+
// returned "n", which made HandleSeries/HandleMovie auto-play the next
24+
// episode forever with zero user input. Failure must quit.
25+
swapFindMenu(t, func(_ []menuItem, _ func(i int) string, _ ...fuzzyfinder.Option) (int, error) {
26+
return 0, errors.New("failed to open TTY")
27+
})
28+
assert.Equal(t, "q", GetUserInput(), "menu failure must map to quit, never to next-episode")
29+
assert.Equal(t, "q", GetUserInput(true), "movie menu failure must map to quit, never to replay")
30+
}
31+
32+
func TestGetUserInput_SeriesMenuMapping(t *testing.T) {
33+
wantLabels := []string{"Next episode", "Previous episode", "Select episode", "Change anime", "← Back", "Quit"}
34+
wantValues := []string{"n", "p", "e", "c", "back", "q"}
35+
36+
for i, wantValue := range wantValues {
37+
var gotLabels []string
38+
swapFindMenu(t, func(items []menuItem, itemFunc func(i int) string, _ ...fuzzyfinder.Option) (int, error) {
39+
gotLabels = nil
40+
for j := range items {
41+
gotLabels = append(gotLabels, itemFunc(j))
42+
}
43+
return i, nil
44+
})
45+
got := GetUserInput()
46+
require.Equal(t, wantLabels, gotLabels)
47+
assert.Equal(t, wantValue, got, "label %q must map to %q", wantLabels[i], wantValue)
48+
}
49+
}
50+
51+
func TestGetUserInput_MovieMenuMapping(t *testing.T) {
52+
wantLabels := []string{"Replay movie", "Change movie", "← Back", "Quit"}
53+
wantValues := []string{"n", "c", "back", "q"}
54+
55+
for i, wantValue := range wantValues {
56+
var gotLabels []string
57+
swapFindMenu(t, func(items []menuItem, itemFunc func(i int) string, _ ...fuzzyfinder.Option) (int, error) {
58+
gotLabels = nil
59+
for j := range items {
60+
gotLabels = append(gotLabels, itemFunc(j))
61+
}
62+
return i, nil
63+
})
64+
got := GetUserInput(true)
65+
require.Equal(t, wantLabels, gotLabels)
66+
assert.Equal(t, wantValue, got, "label %q must map to %q", wantLabels[i], wantValue)
67+
}
68+
}

internal/playback/navigation_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,19 @@ func TestHandleUserNavigationEnhanced_AllAnime_UsesEnhancedHandler(t *testing.T)
126126
assert.Equal(t, 2, n)
127127
}
128128

129+
func TestHandleUserNavigationEnhanced_SuperFlixShortID_NotRoutedToAllAnime(t *testing.T) {
130+
t.Parallel()
131+
// Regression: SuperFlix/SFlix use short numeric TMDB IDs as URLs. The old
132+
// isAllAnimeSource heuristic matched any short non-http URL, sending these
133+
// sources into AllAnime navigation (wrong navigator + network fetch).
134+
eps := []models.Episode{{URL: "u1", Number: "1", Num: 1}, {URL: "u2", Number: "2", Num: 2}}
135+
anime := &models.Anime{Source: "SuperFlix", URL: "1234"}
136+
url, num, n := handleUserNavigationEnhanced("n", eps, 1, 2, anime)
137+
assert.Equal(t, "u2", url)
138+
assert.Equal(t, "2", num)
139+
assert.Equal(t, 2, n)
140+
}
141+
129142
// --- handleAllAnimeNavigation ---
130143

131144
func TestHandleAllAnimeNavigation_NoCurrentEpisode_Fallback(t *testing.T) {

0 commit comments

Comments
 (0)