From 64a60bdb1c1eb050a67c9b351312c5b0ae20673b Mon Sep 17 00:00:00 2001 From: Aryan Chandra Date: Fri, 3 Jul 2026 13:48:49 +0530 Subject: [PATCH] Fix provider search matching/ranking and add curd-web integration hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several real bugs found while building a browser front-end (curd-web) that drives curd through a fake-mpv IPC bridge instead of real mpv: - internal/provider.go: substring containment between a season and its sequel ("Sword Art Online" vs "Sword Art Online II") was scored as a fuzzy-match bonus instead of recognized as a different season. isLikelyDifferentSeason() penalizes titles differing only by a trailing season/sequel token. - internal/anilist.go: search re-ranking used raw Levenshtein distance against the full, unordered query, penalizing legitimate word reordering ("bunny girl senpai rascal does not dream") as a near-total mismatch. wordOverlapScore() (fraction of query words present, order-independent) is now the primary sort key. - internal/selection_menu.go: bubbletea's tea.NewProgram(...).Run() can transiently report "could not open a new TTY" right after a prior Program on the same pty just exited — a real timing race under non-standard terminal backends, not specific to any one frontend. Retried up to 4x/75ms apart, scoped to that exact error string. - internal/provider.go, provider_mapping.go: a previously-saved provider id was trusted without re-verifying it still matches the current AniList media; now re-verified via a fresh provider search (MAL id match, title/season checks, episode-count signal) before playback. - internal/provider_disabled.go: default provider stack reordered to anineko -> anipub -> senshi. Senshi can return metadata-correct (right title/MAL id/episode) but wrong video content — verified live by sampling its HLS output with ffmpeg against the other providers for the same episode. Also adds two small, additive hooks used by curd-web (no effect on normal/standalone use): - CurdWebModeEnabled() (player.go): gated behind a CURD_WEB=1 env var, exposes the active provider name over the existing mpv IPC channel (--curd-web-provider on spawn, user-data/curd-web-provider set_property on reuse) so a browser frontend can show which provider is serving the current stream. - SwitchToSingleProviderStream() + -remap-provider flag (main.go, provider_mapping.go): non-interactively resolves and returns a directly playable stream on one specific provider for one anime/episode, without walking the full provider stack. Exists because providerNamesForAnime deliberately always prefers config stack order over any saved provider (see TestProviderNamesForAnimePrefersStackBeforeSavedProvider) — so a frontend offering a manual "try a different source" action needs a way to get a specific provider's stream directly rather than relying on a persisted mapping that the stack order would otherwise walk straight past. Full regression suite (go test ./...) and go vet ./... pass. --- cmd/curd/main.go | 51 +++++++++- internal/anilist.go | 54 ++++++++++- internal/anilist_search_test.go | 77 +++++++++++++++ internal/logic_regression_test.go | 8 +- internal/player.go | 18 ++++ internal/provider.go | 147 +++++++++++++++++++++++++++-- internal/provider_disabled.go | 2 +- internal/provider_disabled_test.go | 10 +- internal/provider_mapping.go | 113 ++++++++++++++++++++++ internal/provider_mapping_test.go | 35 +++++++ internal/provider_match_test.go | 92 ++++++++++++++++++ internal/provider_migrate_test.go | 2 +- internal/provider_stack_test.go | 20 +++- internal/selection_menu.go | 68 ++++++++----- 14 files changed, 648 insertions(+), 49 deletions(-) create mode 100644 internal/anilist_search_test.go diff --git a/cmd/curd/main.go b/cmd/curd/main.go index 9bbc7be..de0b4bc 100644 --- a/cmd/curd/main.go +++ b/cmd/curd/main.go @@ -1,12 +1,14 @@ package main import ( + "encoding/json" "flag" "fmt" "os" "path/filepath" "runtime" "strconv" + "strings" "sync" "time" @@ -92,6 +94,7 @@ func main() { softSubFlag := flag.Bool("softsub", false, "Prefer soft subtitles when available (anineko)") hardSubFlag := flag.Bool("hardsub", false, "Prefer hard subtitles when available (anineko)") versionFlag := flag.Bool("v", false, "Print version information") + remapProviderFlag := flag.String("remap-provider", "", "Non-interactively resolve one anime/episode against a specific provider: -remap-provider=::. Persists the mapping to local history and prints the resolved stream as JSON on stdout ({providerName,providerId,link,referrer,subtitle}) or {\"error\":...} on failure, then exits; does not start playback.") // Custom help/usage function flag.Usage = func() { @@ -182,10 +185,15 @@ func main() { return } - // Setup screen for interactive mode (only if not changing token) - internal.ClearScreen() - internal.InstallTerminalInterruptHandler() - defer internal.RestoreScreen() + // Setup screen for interactive mode (only if not changing token, and not + // a non-interactive -remap-provider CLI call — that path prints JSON on + // stdout for curd-web to parse, and ClearScreen's terminal escape codes + // would corrupt it). + if *remapProviderFlag == "" { + internal.ClearScreen() + internal.InstallTerminalInterruptHandler() + defer internal.RestoreScreen() + } // Set SubOrDub based on the flags if *subFlag { @@ -211,6 +219,41 @@ func main() { databaseFile := filepath.Join(os.ExpandEnv(userCurdConfig.StoragePath), "curd_history.txt") databaseAnimes := internal.LocalGetAllAnime(databaseFile) + if *remapProviderFlag != "" { + parts := strings.SplitN(*remapProviderFlag, ":", 3) + if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" { + fmt.Println(`{"error":"invalid -remap-provider value, expected ::"}`) + os.Exit(2) + } + anilistID, convErr := strconv.Atoi(parts[0]) + if convErr != nil { + fmt.Printf(`{"error":"invalid anilist id: %s"}`+"\n", convErr) + os.Exit(2) + } + episode, epErr := strconv.Atoi(parts[1]) + if epErr != nil { + fmt.Printf(`{"error":"invalid episode number: %s"}`+"\n", epErr) + os.Exit(2) + } + providerName := parts[2] + if refreshErr := internal.RefreshUserAnimeList(&userCurdConfig, &user); refreshErr != nil { + fmt.Printf(`{"error":"refreshing anime list: %s"}`+"\n", refreshErr) + os.Exit(1) + } + if user.ListSync != nil { + user.AnimeList = user.ListSync.Current() + } + stream, switchErr := internal.SwitchToSingleProviderStream(&userCurdConfig, &user, &databaseAnimes, anilistID, episode, providerName) + if switchErr != nil { + payload, _ := json.Marshal(map[string]string{"error": switchErr.Error()}) + fmt.Println(string(payload)) + os.Exit(1) + } + payload, _ := json.Marshal(stream) + fmt.Println(string(payload)) + os.Exit(0) + } + if *addNewAnime { internal.AddNewAnime(&userCurdConfig, &anime, &user, &databaseAnimes) // internal.ExitCurd(fmt.Errorf("Added new anime!")) diff --git a/internal/anilist.go b/internal/anilist.go index 4934e7d..3c2afc7 100755 --- a/internal/anilist.go +++ b/internal/anilist.go @@ -12,6 +12,7 @@ import ( "strconv" "strings" "time" + "unicode" ) // FindKeyByValue searches for a key associated with a given value in a map[string]string @@ -140,6 +141,39 @@ func min3(a, b, c int) int { return c } +// tokenize splits a title/query into lowercase alphanumeric words, for +// order-independent relevance scoring. +func tokenize(s string) []string { + return strings.FieldsFunc(strings.ToLower(s), func(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsDigit(r) + }) +} + +// wordOverlapScore reports how many of the query's words appear anywhere in +// the title, as a fraction of the query's word count. Levenshtein distance +// alone ranks titles by character-for-character closeness to the query IN +// THE SAME ORDER, so a query combining words from two related titles (e.g. +// "bunny girl senpai rascal does not dream", mixing a TV series' and its +// movie sequel's titles) scores terribly against either real title even +// though every word matches one of them — word overlap doesn't care about +// order or which title contributed which word, only whether the word is +// present, so it survives query reordering/combination that Levenshtein +// treats as a near-total mismatch. +func wordOverlapScore(title, query string) float64 { + queryWords := tokenize(query) + if len(queryWords) == 0 { + return 0 + } + titleLower := strings.ToLower(title) + matched := 0 + for _, w := range queryWords { + if strings.Contains(titleLower, w) { + matched++ + } + } + return float64(matched) / float64(len(queryWords)) +} + func doAniListSearchRequest(url string, requestBody []byte, token string) ([]byte, error) { for authAttempt := 0; authAttempt < 2; authAttempt++ { body, err := doAniListSearchRequestAttempt(url, requestBody, token) @@ -260,6 +294,7 @@ func SearchAnimeAnilistPreview(query, token string) (map[string]RofiSelectPrevie title string cover string episodes int + overlap float64 score int } var scored []scoredAnime @@ -270,10 +305,17 @@ func SearchAnimeAnilistPreview(query, token string) (map[string]RofiSelectPrevie title = anime.Title.Romaji } cover := anime.CoverImage.Large + overlap := wordOverlapScore(title, query) score := levenshtein(title, query) - scored = append(scored, scoredAnime{idStr, title, cover, anime.Episodes, score}) + scored = append(scored, scoredAnime{idStr, title, cover, anime.Episodes, overlap, score}) } sort.Slice(scored, func(i, j int) bool { + // Word overlap first (does the title actually contain the query's + // words, in any order?), Levenshtein only as a tiebreaker between + // equally-relevant titles. + if scored[i].overlap != scored[j].overlap { + return scored[i].overlap > scored[j].overlap + } return scored[i].score < scored[j].score }) for i, s := range scored { @@ -334,6 +376,7 @@ func SearchAnimeAnilist(query, token string) ([]SelectionOption, error) { id string title string episodes int + overlap float64 score int } var scored []scoredAnime @@ -343,10 +386,17 @@ func SearchAnimeAnilist(query, token string) ([]SelectionOption, error) { if title == "" { title = anime.Title.Romaji } + overlap := wordOverlapScore(title, query) score := levenshtein(title, query) - scored = append(scored, scoredAnime{idStr, title, anime.Episodes, score}) + scored = append(scored, scoredAnime{idStr, title, anime.Episodes, overlap, score}) } sort.Slice(scored, func(i, j int) bool { + // Word overlap first (does the title actually contain the query's + // words, in any order?), Levenshtein only as a tiebreaker between + // equally-relevant titles. + if scored[i].overlap != scored[j].overlap { + return scored[i].overlap > scored[j].overlap + } return scored[i].score < scored[j].score }) for i, s := range scored { diff --git a/internal/anilist_search_test.go b/internal/anilist_search_test.go new file mode 100644 index 0000000..127fa3b --- /dev/null +++ b/internal/anilist_search_test.go @@ -0,0 +1,77 @@ +package internal + +import ( + "sort" + "testing" +) + +// Regression test for a real user complaint: searching "bunny girl senpai +// rascal does not dream" (the TV series' title words, reordered/combined) +// should still rank "Rascal Does Not Dream of Bunny Girl Senpai" as the best +// match. Levenshtein distance alone compares characters IN ORDER, so +// reordering a title's own words into a query used to tank its score below +// unrelated candidates that happened to share a longer contiguous run of +// characters with the query. +func TestWordOverlapScoreRanksReorderedQueryTitleHighest(t *testing.T) { + query := "bunny girl senpai rascal does not dream" + got := wordOverlapScore("Rascal Does Not Dream of Bunny Girl Senpai", query) + if got != 1.0 { + t.Fatalf("expected perfect word overlap (1.0) for a title containing every query word, got %v", got) + } +} + +// The movie sequel doesn't contain "bunny"/"senpai", but should still score +// meaningfully (not near-zero the way raw Levenshtein against the full, +// reordered query would) so it survives into a top-10 cutoff. +func TestWordOverlapScorePartialMatchStillMeaningful(t *testing.T) { + query := "bunny girl senpai rascal does not dream" + got := wordOverlapScore("Rascal Does Not Dream of a Dreaming Girl", query) + if got < 0.5 { + t.Fatalf("expected partial word overlap >= 0.5 for a related title sharing most query words, got %v", got) + } +} + +// An unrelated title sharing no words with the query should score zero, +// regardless of any incidental character-level similarity. +func TestWordOverlapScoreUnrelatedTitleScoresZero(t *testing.T) { + query := "bunny girl senpai rascal does not dream" + got := wordOverlapScore("Attack on Titan", query) + if got != 0 { + t.Fatalf("expected zero word overlap for an unrelated title, got %v", got) + } +} + +// End-to-end shape of the actual fix: sorting candidates the same way +// SearchAnimeAnilist does (word overlap first, Levenshtein as tiebreaker) +// must put the real title first even for a jumbled multi-word query, which a +// pure-Levenshtein sort would not reliably do. +func TestSearchRankingPutsReorderedQueryTitleFirst(t *testing.T) { + query := "bunny girl senpai rascal does not dream" + titles := []string{ + "Sword Art Online", + "Rascal Does Not Dream of a Dreaming Girl", + "Rascal Does Not Dream of Bunny Girl Senpai", + "My Teen Romantic Comedy SNAFU", + } + type scored struct { + title string + overlap float64 + dist int + } + var results []scored + for _, title := range titles { + results = append(results, scored{title, wordOverlapScore(title, query), levenshtein(title, query)}) + } + sort.Slice(results, func(i, j int) bool { + if results[i].overlap != results[j].overlap { + return results[i].overlap > results[j].overlap + } + return results[i].dist < results[j].dist + }) + if results[0].title != "Rascal Does Not Dream of Bunny Girl Senpai" { + t.Fatalf("expected the exact-word-match title to rank first, got %q", results[0].title) + } + if results[1].title != "Rascal Does Not Dream of a Dreaming Girl" { + t.Fatalf("expected the related movie to rank second, got %q", results[1].title) + } +} diff --git a/internal/logic_regression_test.go b/internal/logic_regression_test.go index 6b7b58b..2427588 100644 --- a/internal/logic_regression_test.go +++ b/internal/logic_regression_test.go @@ -219,8 +219,8 @@ func TestGetProviderNormalizesConfiguredProviderName(t *testing.T) { CurrentProvider = nil SetGlobalConfig(&CurdConfig{Provider: " AllAnime "}) - if got := GetProvider().Name(); got != "senshi" { - t.Fatalf("expected senshi provider when allanime is disabled, got %q", got) + if got := GetProvider().Name(); got != "anineko" { + t.Fatalf("expected first enabled provider when allanime is disabled, got %q", got) } } @@ -235,8 +235,8 @@ func TestGetProviderFallsBackWhenConfiguredProviderDisabled(t *testing.T) { CurrentProvider = nil SetGlobalConfig(&CurdConfig{Provider: " AnimePahe "}) - if got := GetProvider().Name(); got != "senshi" { - t.Fatalf("expected senshi fallback for disabled provider, got %q", got) + if got := GetProvider().Name(); got != "anineko" { + t.Fatalf("expected first enabled fallback for disabled provider, got %q", got) } } diff --git a/internal/player.go b/internal/player.go index 554ba22..04ec128 100644 --- a/internal/player.go +++ b/internal/player.go @@ -21,6 +21,14 @@ var logFile = "debug.log" // This is not generic but we have MpvArgs in CurdConfig to add custom ones const defaultStreamReferrer = "https://allanime.day/" +// CurdWebModeEnabled reports whether curd is running under curd-web's fake-mpv +// bridge. It gates curd-web-only behavior (like exposing the active provider +// name to the player) so a real, standalone mpv never sees a custom flag or +// property it doesn't understand. +func CurdWebModeEnabled() bool { + return os.Getenv("CURD_WEB") == "1" +} + func streamReferrerForLink(link, provider string) string { if strings.Contains(strings.ToLower(link), "tools.fast4speed.rsvp") { return "https://allanime.to" @@ -341,6 +349,13 @@ func StartVideo(link string, args []string, title string, anime *Anime) (string, Log(fmt.Sprintf("Failed to update window title: %v", err)) } + if CurdWebModeEnabled() { + providerCommand := []interface{}{"set_property", "user-data/curd-web-provider", CurrentAnimeProviderName(anime)} + if _, err = MPVSendCommand(mpvSocketPath, providerCommand); err != nil { + Log(fmt.Sprintf("Failed to update curd-web provider property: %v", err)) + } + } + return mpvSocketPath, nil } @@ -371,6 +386,9 @@ func StartVideo(link string, args []string, title string, anime *Anime) (string, // Keep the window open after episode completes, new episode starts in the same mpv window args = append(args, "--force-window=yes", "--idle=yes") args = append(args, titleArgs...) + if CurdWebModeEnabled() { + args = append(args, fmt.Sprintf("--curd-web-provider=%s", CurrentAnimeProviderName(anime))) + } // Prepare arguments for mpv-compatible players. var mpvArgs []string diff --git a/internal/provider.go b/internal/provider.go index 7672b98..2fed4fe 100644 --- a/internal/provider.go +++ b/internal/provider.go @@ -6,7 +6,8 @@ import ( "strings" "github.com/wraient/curd/internal/providers" - "github.com/wraient/curd/internal/providers/animepahe" + "github.com/wraient/curd/internal/providers/anipub" + "github.com/wraient/curd/internal/providers/senshi" ) // Provider interface defines methods for an anime provider. @@ -829,6 +830,47 @@ func normalizeSearchTitle(title string) string { return strings.Join(strings.Fields(title), " ") } +// sequelMarkerTokens lists words/numerals that, when they're the *only* extra +// tokens distinguishing two otherwise-identical titles, mark a different +// season/entry in the same franchise (e.g. "Sword Art Online" vs "Sword Art +// Online II", which is a separate 24-episode show, not a looser-formatted +// title for the same 25-episode one). +var sequelMarkerTokens = map[string]bool{ + "ii": true, "iii": true, "iv": true, "v": true, "vi": true, "vii": true, "viii": true, "ix": true, "x": true, + "2": true, "3": true, "4": true, "5": true, "6": true, "7": true, "8": true, "9": true, + "2nd": true, "3rd": true, "4th": true, "5th": true, "6th": true, + "first": true, "second": true, "third": true, "fourth": true, "fifth": true, + "season": true, "cour": true, "part": true, "final": true, + "movie": true, "ova": true, "ona": true, "special": true, "recap": true, +} + +// isLikelyDifferentSeason reports whether the longer of two normalized titles +// is exactly the shorter title plus a trailing run of season/sequel tokens. +// Plain substring containment (e.g. "Attack on Titan" vs "Attack on Titan: +// Final Season") would otherwise score these as a fuzzy match on the same +// show, when they're actually different entries with their own episode +// numbering — the classic source of "picked season 2's stream for season 1" +// mismatches. +func isLikelyDifferentSeason(a, b string) bool { + shorter, longer := a, b + if len(a) > len(b) { + shorter, longer = b, a + } + if shorter == "" || !strings.HasPrefix(longer, shorter) { + return false + } + remainder := strings.TrimSpace(strings.TrimPrefix(longer, shorter)) + if remainder == "" { + return false + } + for _, tok := range strings.Fields(remainder) { + if !sequelMarkerTokens[tok] { + return false + } + } + return true +} + func scoreProviderSearchOption(option SelectionOption, anime *Anime, query string, index, total int) int { optionTitle := normalizeSearchTitle(option.Title) if optionTitle == "" { @@ -849,6 +891,8 @@ func scoreProviderSearchOption(option SelectionOption, anime *Anime, query strin } if optionTitle == targetTitle { score += 100 + } else if isLikelyDifferentSeason(optionTitle, targetTitle) { + score -= 50 } else if strings.Contains(optionTitle, targetTitle) || strings.Contains(targetTitle, optionTitle) { score += 35 } @@ -856,15 +900,102 @@ func scoreProviderSearchOption(option SelectionOption, anime *Anime, query strin if anime.AnilistId != 0 && strings.Contains(option.Thumbnail, fmt.Sprintf("%d", anime.AnilistId)) { score += 120 } - if anime.TotalEpisodes > 0 && strings.Contains(option.Label, fmt.Sprintf("(%d episodes)", anime.TotalEpisodes)) { - score += 20 + if anime.MalId != 0 && providerOptionMalID(option) == anime.MalId { + score += 140 } - if item, ok := option.ExtraData.(animepahe.SearchItem); ok && anime.TotalEpisodes > 0 && item.Episodes == anime.TotalEpisodes { - score += 20 + if count, ok := episodeCountFromSelectionOption(option); ok && anime.TotalEpisodes > 0 && count == anime.TotalEpisodes { + score += 30 } return score } +func providerOptionMalID(option SelectionOption) int { + switch item := option.ExtraData.(type) { + case anipub.SearchItem: + return item.MalID + case senshi.SearchItem: + return item.MalID + default: + return 0 + } +} + +func rawProviderOptionKey(providerName string, option SelectionOption) (string, bool) { + if option.Key == "" { + return "", false + } + if optionProviderName, rawProviderID, ok := ParseProviderQualifiedID(option.Key); ok { + return rawProviderID, optionProviderName == normalizeProviderName(providerName) + } + return option.Key, true +} + +func providerOptionKeyMatches(providerName string, option SelectionOption, providerID string) bool { + rawKey, ok := rawProviderOptionKey(providerName, option) + return ok && rawKey == providerID +} + +func providerSearchScore(option SelectionOption, anime *Anime, query string, index, total int) int { + return scoreProviderSearchOption(option, anime, query, index, total) +} + +func verifiedSavedProviderID(provider Provider, anime *Anime, mode, providerID string) (string, bool) { + query := animeSearchTitle(anime) + if query == "" { + return providerID, true + } + + options, err := provider.SearchAnime(query, mode) + if err != nil { + Log(fmt.Sprintf("Could not verify saved %s provider id %q for %q: %v; using saved id", provider.Name(), providerID, query, err)) + return providerID, true + } + if len(options) == 0 { + Log(fmt.Sprintf("Could not verify saved %s provider id %q for %q: no search results; using saved id", provider.Name(), providerID, query)) + return providerID, true + } + + bestIndex := -1 + bestScore := -1 + savedIndex := -1 + savedScore := -1 + for i, option := range options { + score := providerSearchScore(option, anime, query, i, len(options)) + if score > bestScore { + bestScore = score + bestIndex = i + } + if providerOptionKeyMatches(provider.Name(), option, providerID) { + savedIndex = i + savedScore = score + } + } + + if savedIndex == -1 { + if bestIndex >= 0 && bestScore >= 100 { + best := options[bestIndex] + bestID, _ := rawProviderOptionKey(provider.Name(), best) + Log(fmt.Sprintf("Saved %s provider id %q for %q was not in search results; replacing with stronger match %q (%s, score %d)", provider.Name(), providerID, query, bestID, best.Label, bestScore)) + return bestID, false + } + Log(fmt.Sprintf("Saved %s provider id %q for %q was not in search results and no confident replacement exists; using saved id", provider.Name(), providerID, query)) + return providerID, true + } + + if savedScore >= 100 && bestScore-savedScore < 30 { + return providerID, true + } + + if bestIndex >= 0 && bestIndex != savedIndex && bestScore >= 100 { + best := options[bestIndex] + bestID, _ := rawProviderOptionKey(provider.Name(), best) + Log(fmt.Sprintf("Saved %s provider id %q for %q is weak (%s, score %d); replacing with %q (%s, score %d)", provider.Name(), providerID, query, options[savedIndex].Label, savedScore, bestID, best.Label, bestScore)) + return bestID, false + } + + return providerID, true +} + func selectBestProviderSearchResult(options []SelectionOption, anime *Anime, query string) (SelectionOption, bool) { if len(options) == 0 { return SelectionOption{}, false @@ -916,7 +1047,11 @@ func confidentProviderSearchMatch(options []SelectionOption, anime *Anime, query func findProviderIDForAnime(provider Provider, anime *Anime, mode string) (string, error) { currentProviderName, currentProviderID := providerIDForAnime(anime) if currentProviderName == provider.Name() && currentProviderID != "" { - return currentProviderID, nil + providerID, verified := verifiedSavedProviderID(provider, anime, mode, currentProviderID) + if verified { + return providerID, nil + } + return providerID, nil } query := animeSearchTitle(anime) diff --git a/internal/provider_disabled.go b/internal/provider_disabled.go index bec139e..965243e 100644 --- a/internal/provider_disabled.go +++ b/internal/provider_disabled.go @@ -100,7 +100,7 @@ func filterEnabledProviders(names []string) []string { return enabled } -var preferredProviderOrder = []string{"senshi", "anipub", "anineko", "allanime", "animepahe"} +var preferredProviderOrder = []string{"anineko", "anipub", "senshi", "allanime", "animepahe"} func defaultEnabledProviderStack() []string { registered := providers.RegisteredNames() diff --git a/internal/provider_disabled_test.go b/internal/provider_disabled_test.go index b51b5e4..d7bd97b 100644 --- a/internal/provider_disabled_test.go +++ b/internal/provider_disabled_test.go @@ -29,11 +29,11 @@ func TestConfiguredProviderNamesFiltersDisabledProviders(t *testing.T) { cfg *CurdConfig want []string }{ - {name: "empty", cfg: &CurdConfig{}, want: []string{"senshi", "anipub", "anineko"}}, - {name: "json list", cfg: &CurdConfig{Provider: `["allanime","animepahe"]`}, want: []string{"senshi"}}, - {name: "animepahe only", cfg: &CurdConfig{Provider: `["animepahe"]`}, want: []string{"senshi"}}, - {name: "allanime only", cfg: &CurdConfig{Provider: `["allanime"]`}, want: []string{"senshi"}}, - {name: "legacy alias", cfg: &CurdConfig{Provider: "stacked"}, want: []string{"senshi", "anipub", "anineko"}}, + {name: "empty", cfg: &CurdConfig{}, want: []string{"anineko", "anipub", "senshi"}}, + {name: "json list", cfg: &CurdConfig{Provider: `["allanime","animepahe"]`}, want: []string{"anineko"}}, + {name: "animepahe only", cfg: &CurdConfig{Provider: `["animepahe"]`}, want: []string{"anineko"}}, + {name: "allanime only", cfg: &CurdConfig{Provider: `["allanime"]`}, want: []string{"anineko"}}, + {name: "legacy alias", cfg: &CurdConfig{Provider: "stacked"}, want: []string{"anineko", "anipub", "senshi"}}, } for _, tc := range cases { diff --git a/internal/provider_mapping.go b/internal/provider_mapping.go index 9ee024f..310f9b3 100644 --- a/internal/provider_mapping.go +++ b/internal/provider_mapping.go @@ -12,6 +12,7 @@ import ( "github.com/wraient/curd/internal/providers/animepahe" "github.com/wraient/curd/internal/providers/anipub" + "github.com/wraient/curd/internal/providers/senshi" ) type ProviderMappingOutcome int @@ -696,6 +697,116 @@ func RemapProviderAnime(userCurdConfig *CurdConfig, user *User, databaseAnimes * } } +// SwitchProviderStream is what curd-web's "switch source" button actually +// needs: a directly playable stream on one specific provider, right now. +type SwitchProviderStream struct { + ProviderName string `json:"providerName"` + ProviderID string `json:"providerId"` + Link string `json:"link"` + Referrer string `json:"referrer"` + Subtitle string `json:"subtitle"` +} + +// SwitchToSingleProviderStream re-resolves the provider mapping for a single +// anime against exactly one named provider, non-interactively, persists it to +// local history (best-effort — a future session's own stack-order search may +// still land elsewhere; see providerNamesForAnime, which deliberately always +// prefers config stack order over any saved provider), and resolves a +// directly playable stream on that provider for the given episode. +// +// This exists instead of just persisting the remap and telling curd to +// replay the episode because curd's own provider resolution +// (episodeModeResultWithProviders) always walks the configured provider +// stack in its fixed order and only falls back to a saved/remapped provider +// if every earlier provider in the stack fails outright — so a remap to a +// provider that isn't first in the stack could otherwise silently never take +// effect, even seconds after the user explicitly chose it. Resolving the +// stream here and having curd-web hand mpv the URL directly sidesteps that +// entirely for this one immediate "watch it on this source now" action. +// +// Unlike RemapProviderAnime (which walks the user through anime selection +// and, on a weak match, an interactive pick/search-again prompt), this only +// succeeds when autoMatchProviderListing finds a confident match on the +// requested provider — it never blocks on stdin. Intended for a fire-and- +// forget CLI invocation. +func SwitchToSingleProviderStream(userCurdConfig *CurdConfig, user *User, databaseAnimes *[]Anime, anilistID, episode int, providerName string) (*SwitchProviderStream, error) { + if userCurdConfig == nil || user == nil { + return nil, fmt.Errorf("missing config or user") + } + providerName = normalizeProviderName(providerName) + if providerName == "" { + return nil, fmt.Errorf("unknown provider name") + } + if episode <= 0 { + return nil, fmt.Errorf("invalid episode number %d", episode) + } + + anilistEntry, err := FindAnimeByAnilistID(user.AnimeList, strconv.Itoa(anilistID)) + if err != nil || anilistEntry == nil { + return nil, fmt.Errorf("anime %d not found in anilist list: %w", anilistID, err) + } + + query := mediaDisplayTitle(anilistEntry.Media, userCurdConfig) + anime := Anime{ + AnilistId: anilistEntry.Media.ID, + MalId: anilistEntry.Media.MalID, + Title: anilistEntry.Media.Title, + TotalEpisodes: anilistEntry.Media.Episodes, + CoverImage: anilistEntry.CoverImage, + } + + state := &providerMappingSearchState{ + query: query, + allProviders: []string{providerName}, + sequential: true, + providerIndex: 0, + } + + animeList, err := searchAnimeForMapping(userCurdConfig, state, userCurdConfig.SubOrDub) + if err != nil { + return nil, fmt.Errorf("provider search failed: %w", err) + } + if len(animeList) == 0 { + return nil, fmt.Errorf("no results from provider %s", providerName) + } + + anime.ProviderId = "" + if !autoMatchProviderListing(userCurdConfig, &anime, animeList, query, anilistEntry) { + return nil, fmt.Errorf("no confident match on provider %s", providerName) + } + applyMatchedProviderMapping(userCurdConfig, state, &anime) + + provider, err := ProviderByName(providerName) + if err != nil { + return nil, fmt.Errorf("provider %s unavailable: %w", providerName, err) + } + links, hints, err := getProviderEpisodeURLForModeWithHints(provider, *userCurdConfig, anime.ProviderId, episode, userCurdConfig.SubOrDub) + if err != nil { + return nil, fmt.Errorf("failed to fetch episode %d from %s: %w", episode, providerName, err) + } + if len(links) == 0 { + return nil, fmt.Errorf("no episode %d links found on %s", episode, providerName) + } + applyStreamPlaybackHints(&anime, links, hints) + referrer := anime.Ep.StreamReferrer + if referrer == "" && isHTTPStreamLink(links[0]) { + referrer = streamReferrerForLink(links[0], providerName) + } + + historyPath := filepath.Join(os.ExpandEnv(userCurdConfig.StoragePath), "curd_history.txt") + if err := persistRemappedProvider(historyPath, databaseAnimes, anilistEntry, &anime, query); err != nil { + return nil, fmt.Errorf("failed to save remapped provider: %w", err) + } + + return &SwitchProviderStream{ + ProviderName: providerName, + ProviderID: anime.ProviderId, + Link: links[0], + Referrer: referrer, + Subtitle: anime.Ep.SubtitleURL, + }, nil +} + func persistRemappedProvider(historyPath string, databaseAnimes *[]Anime, anilistEntry *Entry, anime *Anime, animeName string) error { if anime == nil || anilistEntry == nil { return fmt.Errorf("missing anime data") @@ -917,6 +1028,8 @@ func malIDFromProviderExtraData(extra any) int { switch item := extra.(type) { case anipub.SearchItem: return item.MalID + case senshi.SearchItem: + return item.MalID default: return 0 } diff --git a/internal/provider_mapping_test.go b/internal/provider_mapping_test.go index 17d39ca..ece6a75 100644 --- a/internal/provider_mapping_test.go +++ b/internal/provider_mapping_test.go @@ -92,6 +92,41 @@ func TestProviderNameFromSelectionUsesQualifiedKey(t *testing.T) { } } +func TestSwitchToSingleProviderStreamValidatesInput(t *testing.T) { + withAllProvidersEnabledForTest(t) + config := &CurdConfig{Provider: `["senshi","anineko"]`} + user := &User{ + AnimeList: AnimeList{ + Watching: []Entry{{Media: Media{ID: 11757, Title: AnimeTitle{Romaji: "Sword Art Online"}}}}, + }, + } + var databaseAnimes []Anime + + t.Run("empty provider name is rejected before any search", func(t *testing.T) { + if _, err := SwitchToSingleProviderStream(config, user, &databaseAnimes, 11757, 3, ""); err == nil { + t.Fatal("expected error for empty provider name") + } + }) + + t.Run("invalid episode number is rejected before any search", func(t *testing.T) { + if _, err := SwitchToSingleProviderStream(config, user, &databaseAnimes, 11757, 0, "anipub"); err == nil { + t.Fatal("expected error for episode <= 0") + } + }) + + t.Run("unknown anilist id is rejected before any search", func(t *testing.T) { + if _, err := SwitchToSingleProviderStream(config, user, &databaseAnimes, 999999, 3, "anipub"); err == nil { + t.Fatal("expected error for anime not present in the user's list") + } + }) + + t.Run("nil user is rejected", func(t *testing.T) { + if _, err := SwitchToSingleProviderStream(config, nil, &databaseAnimes, 11757, 3, "anipub"); err == nil { + t.Fatal("expected error for nil user") + } + }) +} + func TestApplyMatchedProviderMappingUsesSequentialProvider(t *testing.T) { withAllProvidersEnabledForTest(t) config := &CurdConfig{Provider: `["senshi","anineko"]`} diff --git a/internal/provider_match_test.go b/internal/provider_match_test.go index 1db8baf..914c670 100644 --- a/internal/provider_match_test.go +++ b/internal/provider_match_test.go @@ -4,8 +4,24 @@ import ( "regexp" "strings" "testing" + + "github.com/wraient/curd/internal/providers/senshi" ) +type fakeSearchProvider struct { + name string + options []SelectionOption +} + +func (p fakeSearchProvider) Name() string { return p.name } +func (p fakeSearchProvider) SearchAnime(query, mode string) ([]SelectionOption, error) { + return p.options, nil +} +func (p fakeSearchProvider) EpisodesList(showID, mode string) ([]string, error) { return nil, nil } +func (p fakeSearchProvider) GetEpisodeURL(config CurdConfig, id string, epNo int) ([]string, error) { + return nil, nil +} + func TestSelectBestProviderSearchResultMarchComesInLikeALion(t *testing.T) { anime := &Anime{ AnilistId: 21366, @@ -66,3 +82,79 @@ func TestMalThumbnailMatchMarchComesInLikeALion(t *testing.T) { t.Fatal("expected MAL thumbnail match") } } + +func TestFindProviderIDForAnimeReplacesWeakSavedDifferentEntry(t *testing.T) { + anime := &Anime{ + AnilistId: 11757, + MalId: 11757, + TotalEpisodes: 25, + ProviderName: "senshi", + ProviderId: "36475", + Title: AnimeTitle{English: "Sword Art Online", Romaji: "Sword Art Online"}, + } + provider := fakeSearchProvider{ + name: "senshi", + options: []SelectionOption{ + { + Key: "36475", + Title: "Sword Art Online Alternative: Gun Gale Online", + Label: "Sword Art Online Alternative: Gun Gale Online · TV · 2018 · 12 eps", + Thumbnail: "https://senshi.live/posters/36475.webp", + ExtraData: senshi.SearchItem{MalID: 36475, Title: "Sword Art Online Alternative: Gun Gale Online", Episodes: 12}, + }, + { + Key: "11757", + Title: "Sword Art Online", + Label: "Sword Art Online · TV · 2012 · 25 eps", + Thumbnail: "https://senshi.live/posters/11757.webp", + ExtraData: senshi.SearchItem{MalID: 11757, Title: "Sword Art Online", Episodes: 25}, + }, + }, + } + + got, err := findProviderIDForAnime(provider, anime, "sub") + if err != nil { + t.Fatalf("findProviderIDForAnime: %v", err) + } + if got != "11757" { + t.Fatalf("expected stale GGO provider id to be replaced with SAO id 11757, got %q", got) + } +} + +func TestFindProviderIDForAnimeKeepsStrongSavedMatch(t *testing.T) { + anime := &Anime{ + AnilistId: 11757, + MalId: 11757, + TotalEpisodes: 25, + ProviderName: "senshi", + ProviderId: "11757", + Title: AnimeTitle{English: "Sword Art Online", Romaji: "Sword Art Online"}, + } + provider := fakeSearchProvider{ + name: "senshi", + options: []SelectionOption{ + { + Key: "36475", + Title: "Sword Art Online Alternative: Gun Gale Online", + Label: "Sword Art Online Alternative: Gun Gale Online · TV · 2018 · 12 eps", + Thumbnail: "https://senshi.live/posters/36475.webp", + ExtraData: senshi.SearchItem{MalID: 36475, Title: "Sword Art Online Alternative: Gun Gale Online", Episodes: 12}, + }, + { + Key: "11757", + Title: "Sword Art Online", + Label: "Sword Art Online · TV · 2012 · 25 eps", + Thumbnail: "https://senshi.live/posters/11757.webp", + ExtraData: senshi.SearchItem{MalID: 11757, Title: "Sword Art Online", Episodes: 25}, + }, + }, + } + + got, err := findProviderIDForAnime(provider, anime, "sub") + if err != nil { + t.Fatalf("findProviderIDForAnime: %v", err) + } + if got != "11757" { + t.Fatalf("expected strong saved SAO provider id to stay 11757, got %q", got) + } +} diff --git a/internal/provider_migrate_test.go b/internal/provider_migrate_test.go index da65c4e..2be8429 100644 --- a/internal/provider_migrate_test.go +++ b/internal/provider_migrate_test.go @@ -117,7 +117,7 @@ func TestMigrateOnVersionUpgradeWritesVersionAndUpdatesProvider(t *testing.T) { func TestConfiguredProviderNamesUsesStackedByDefault(t *testing.T) { withAllProvidersEnabledForTest(t) got := ConfiguredProviderNames(&CurdConfig{}) - want := []string{"senshi", "anipub", "anineko", "allanime", "animepahe"} + want := []string{"anineko", "anipub", "senshi", "allanime", "animepahe"} if len(got) != len(want) { t.Fatalf("got %v, want %v", got, want) } diff --git a/internal/provider_stack_test.go b/internal/provider_stack_test.go index 8b249b2..39b20d1 100644 --- a/internal/provider_stack_test.go +++ b/internal/provider_stack_test.go @@ -95,11 +95,11 @@ func TestConfiguredProviderNamesAcceptsOrderedLists(t *testing.T) { cfg *CurdConfig want []string }{ - {name: "empty", cfg: &CurdConfig{}, want: []string{"senshi", "anipub", "anineko", "allanime", "animepahe"}}, + {name: "empty", cfg: &CurdConfig{}, want: []string{"anineko", "anipub", "senshi", "allanime", "animepahe"}}, {name: "json list", cfg: &CurdConfig{Provider: `["allanime","animepahe"]`}, want: []string{"allanime", "animepahe"}}, {name: "comma list", cfg: &CurdConfig{Provider: "animepahe,allanime"}, want: []string{"animepahe", "allanime"}}, {name: "plus list", cfg: &CurdConfig{Provider: "allanime+animepahe"}, want: []string{"allanime", "animepahe"}}, - {name: "legacy alias", cfg: &CurdConfig{Provider: "stacked"}, want: []string{"senshi", "anipub", "anineko", "allanime", "animepahe"}}, + {name: "legacy alias", cfg: &CurdConfig{Provider: "stacked"}, want: []string{"anineko", "anipub", "senshi", "allanime", "animepahe"}}, } for _, tc := range cases { @@ -115,6 +115,22 @@ func TestConfiguredProviderNamesAcceptsOrderedLists(t *testing.T) { } } +func TestProviderNamesForAnimePrefersStackBeforeSavedProvider(t *testing.T) { + config := &CurdConfig{Provider: "stacked"} + anime := &Anime{ProviderName: "senshi", ProviderId: "11757"} + + got := providerNamesForAnime(config, anime) + want := []string{"anineko", "anipub", "senshi"} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } +} + func TestCanonicalProviderConfigValueMigratesLegacyValuesWithoutAddingAnimepahe(t *testing.T) { cases := []struct { name string diff --git a/internal/selection_menu.go b/internal/selection_menu.go index 6488fb8..1589e74 100644 --- a/internal/selection_menu.go +++ b/internal/selection_menu.go @@ -687,36 +687,56 @@ func dynamicSelectInternal(options []SelectionOption, refreshConfig *SelectionRe } model.filterOptions() - p := tea.NewProgram(model) - stopRefresh := make(chan struct{}) - - if refreshConfig != nil && refreshConfig.Updates != nil { - go func(lastOptions []SelectionOption) { - currentOptions := lastOptions - for { - select { - case <-stopRefresh: - return - case updatedList, ok := <-refreshConfig.Updates: - if !ok { + // bubbletea's terminal-detection (term.IsTerminal on os.Stdin in + // initInput) can transiently report "not a terminal" immediately after a + // *previous* bubbletea Program on the same pty has just exited, which + // sends it down a fallback path that opens /dev/tty directly — observed + // live under curd-web's node-pty-backed terminal as "could not open a + // new TTY: open /dev/tty: device not configured" on back-to-back + // DynamicSelect calls (e.g. picking a search result immediately followed + // by picking a category to add it to). A fresh Program a moment later + // reliably succeeds, so retry this specific, transient input-setup error + // instead of surfacing it as an unrecoverable failure and killing curd. + var finalModel tea.Model + var err error + for attempt := 0; attempt < 4; attempt++ { + p := tea.NewProgram(model) + stopRefresh := make(chan struct{}) + + if refreshConfig != nil && refreshConfig.Updates != nil { + go func(lastOptions []SelectionOption) { + currentOptions := lastOptions + for { + select { + case <-stopRefresh: return + case updatedList, ok := <-refreshConfig.Updates: + if !ok { + return + } + + updatedOptions := refreshConfig.BuildOptions(updatedList) + if reflect.DeepEqual(currentOptions, updatedOptions) { + continue + } + + currentOptions = updatedOptions + p.Send(optionsRefreshedMsg{options: updatedOptions}) } + } + }(append([]SelectionOption(nil), options...)) + } - updatedOptions := refreshConfig.BuildOptions(updatedList) - if reflect.DeepEqual(currentOptions, updatedOptions) { - continue - } + finalModel, err = p.Run() + close(stopRefresh) - currentOptions = updatedOptions - p.Send(optionsRefreshedMsg{options: updatedOptions}) - } - } - }(append([]SelectionOption(nil), options...)) + if err == nil || !strings.Contains(err.Error(), "could not open a new TTY") { + break + } + Log(fmt.Sprintf("bubbletea input race on attempt %d, retrying: %v", attempt+1, err)) + time.Sleep(75 * time.Millisecond) } - finalModel, err := p.Run() - close(stopRefresh) - // Bubbletea may leave the terminal in raw mode; always reset cursor state. fmt.Print("\033[?25h") fmt.Print("\033[?7h")