Skip to content
Open
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
51 changes: 47 additions & 4 deletions cmd/curd/main.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package main

import (
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"

Expand Down Expand Up @@ -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=<anilistId>:<episode>:<providerName>. 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() {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 <anilistId>:<episode>:<providerName>"}`)
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!"))
Expand Down
54 changes: 52 additions & 2 deletions internal/anilist.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"strconv"
"strings"
"time"
"unicode"
)

// FindKeyByValue searches for a key associated with a given value in a map[string]string
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -334,6 +376,7 @@ func SearchAnimeAnilist(query, token string) ([]SelectionOption, error) {
id string
title string
episodes int
overlap float64
score int
}
var scored []scoredAnime
Expand All @@ -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 {
Expand Down
77 changes: 77 additions & 0 deletions internal/anilist_search_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
8 changes: 4 additions & 4 deletions internal/logic_regression_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand All @@ -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)
}
}

Expand Down
18 changes: 18 additions & 0 deletions internal/player.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading