Skip to content

Commit 1bfbdda

Browse files
committed
Add: Senshi provider and related tests
- Implemented the Senshi provider for anime catalog search and streaming. - Added functionality for searching anime, retrieving episode lists, and fetching streaming URLs. - Created comprehensive tests for the Senshi provider, including search results parsing and episode streaming. - Introduced live edge-case tests for various anime queries across multiple providers. - Added benchmarking tests to compare performance between Senshi and Anineko APIs. - Implemented terminal interrupt handling to restore the terminal state on exit. - Added version management for the application.
1 parent e857c86 commit 1bfbdda

44 files changed

Lines changed: 3132 additions & 63 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Build/update-aur.sh

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
#!/usr/bin/env bash
2+
3+
set -euo pipefail
4+
5+
AUR_REPO="${AUR_REPO:-$HOME/Projects/aur/curd}"
6+
UPSTREAM_REPO="${UPSTREAM_REPO:-Wraient/curd}"
7+
ASSET_NAME="${ASSET_NAME:-curd-linux-x86_64}"
8+
REMOTE="${REMOTE:-origin}"
9+
DRY_RUN="false"
10+
11+
usage() {
12+
cat <<'EOF'
13+
Usage: Build/update-aur.sh [--dry-run]
14+
15+
Environment overrides:
16+
AUR_REPO AUR git clone path (default: ~/Projects/aur/curd)
17+
UPSTREAM_REPO GitHub repo in owner/name format (default: Wraient/curd)
18+
ASSET_NAME Release asset to checksum (default: curd-linux-x86_64)
19+
REMOTE Git remote to push (default: origin)
20+
EOF
21+
}
22+
23+
require_cmd() {
24+
if ! command -v "$1" >/dev/null 2>&1; then
25+
echo "Missing required command: $1" >&2
26+
exit 1
27+
fi
28+
}
29+
30+
for arg in "$@"; do
31+
case "$arg" in
32+
--dry-run)
33+
DRY_RUN="true"
34+
;;
35+
-h|--help)
36+
usage
37+
exit 0
38+
;;
39+
*)
40+
echo "Unknown argument: $arg" >&2
41+
usage
42+
exit 1
43+
;;
44+
esac
45+
done
46+
47+
require_cmd curl
48+
require_cmd jq
49+
require_cmd git
50+
require_cmd sed
51+
require_cmd sha256sum
52+
53+
if [[ ! -d "$AUR_REPO/.git" ]]; then
54+
echo "AUR repo not found at $AUR_REPO" >&2
55+
exit 1
56+
fi
57+
58+
if [[ "$DRY_RUN" == "true" ]]; then
59+
echo "Dry run: skipping AUR repo pull."
60+
else
61+
echo "Syncing AUR repo with upstream..."
62+
git -C "$AUR_REPO" pull --ff-only "$REMOTE"
63+
fi
64+
65+
PKGBUILD_PATH="$AUR_REPO/PKGBUILD"
66+
if [[ ! -f "$PKGBUILD_PATH" ]]; then
67+
echo "PKGBUILD not found at $PKGBUILD_PATH" >&2
68+
exit 1
69+
fi
70+
71+
release_api="https://api.github.com/repos/${UPSTREAM_REPO}/releases/latest"
72+
release_json="$(curl -fsSL "$release_api")"
73+
74+
tag_name="$(printf '%s' "$release_json" | jq -r '.tag_name')"
75+
if [[ -z "$tag_name" || "$tag_name" == "null" ]]; then
76+
echo "Could not determine latest release tag from $release_api" >&2
77+
exit 1
78+
fi
79+
80+
pkgver="${tag_name#v}"
81+
download_url="$(printf '%s' "$release_json" | jq -r --arg n "$ASSET_NAME" '.assets[] | select(.name == $n) | .browser_download_url' | head -n1)"
82+
if [[ -z "$download_url" || "$download_url" == "null" ]]; then
83+
echo "Asset $ASSET_NAME not found in release $tag_name" >&2
84+
exit 1
85+
fi
86+
87+
digest="$(printf '%s' "$release_json" | jq -r --arg n "$ASSET_NAME" '.assets[] | select(.name == $n) | (.digest // empty)' | head -n1)"
88+
if [[ "$digest" == sha256:* ]]; then
89+
sha256="${digest#sha256:}"
90+
else
91+
tmp_file="$(mktemp)"
92+
trap 'rm -f "$tmp_file"' EXIT
93+
curl -fL "$download_url" -o "$tmp_file"
94+
sha256="$(sha256sum "$tmp_file" | awk '{print $1}')"
95+
rm -f "$tmp_file"
96+
trap - EXIT
97+
fi
98+
99+
source_line="source=(\"https://github.com/${UPSTREAM_REPO}/releases/download/v\${pkgver}/${ASSET_NAME}\")"
100+
sha_line="sha256sums=('${sha256}')"
101+
102+
PKGBUILD_BACKUP="$(mktemp)"
103+
SRCINFO_PATH="$AUR_REPO/.SRCINFO"
104+
SRCINFO_BACKUP=""
105+
cp "$PKGBUILD_PATH" "$PKGBUILD_BACKUP"
106+
if [[ -f "$SRCINFO_PATH" ]]; then
107+
SRCINFO_BACKUP="$(mktemp)"
108+
cp "$SRCINFO_PATH" "$SRCINFO_BACKUP"
109+
fi
110+
111+
restore_dry_run_files() {
112+
if [[ "$DRY_RUN" == "true" ]]; then
113+
cp "$PKGBUILD_BACKUP" "$PKGBUILD_PATH"
114+
if [[ -n "$SRCINFO_BACKUP" ]]; then
115+
cp "$SRCINFO_BACKUP" "$SRCINFO_PATH"
116+
else
117+
rm -f "$SRCINFO_PATH"
118+
fi
119+
fi
120+
rm -f "$PKGBUILD_BACKUP"
121+
if [[ -n "$SRCINFO_BACKUP" ]]; then
122+
rm -f "$SRCINFO_BACKUP"
123+
fi
124+
}
125+
trap restore_dry_run_files EXIT
126+
127+
sed -i -E "s/^pkgver=.*/pkgver=${pkgver}/" "$PKGBUILD_PATH"
128+
129+
if grep -q '^source=' "$PKGBUILD_PATH"; then
130+
sed -i -E "s|^source=.*$|${source_line}|" "$PKGBUILD_PATH"
131+
else
132+
if grep -q '^conflicts=' "$PKGBUILD_PATH"; then
133+
sed -i "/^conflicts=.*/a ${source_line}" "$PKGBUILD_PATH"
134+
else
135+
printf '\n%s\n' "$source_line" >> "$PKGBUILD_PATH"
136+
fi
137+
fi
138+
139+
if grep -q '^sha256sums=' "$PKGBUILD_PATH"; then
140+
sed -i -E "s|^sha256sums=.*$|${sha_line}|" "$PKGBUILD_PATH"
141+
else
142+
if grep -q '^source=' "$PKGBUILD_PATH"; then
143+
sed -i "/^source=.*/a ${sha_line}" "$PKGBUILD_PATH"
144+
else
145+
printf '%s\n' "$sha_line" >> "$PKGBUILD_PATH"
146+
fi
147+
fi
148+
149+
if command -v makepkg >/dev/null 2>&1; then
150+
(cd "$AUR_REPO" && makepkg --printsrcinfo > .SRCINFO)
151+
else
152+
echo "Warning: makepkg not found; skipping .SRCINFO refresh" >&2
153+
fi
154+
155+
if cmp -s "$PKGBUILD_PATH" "$PKGBUILD_BACKUP" && [[ ! -f "$AUR_REPO/.SRCINFO" || -z "$(git -C "$AUR_REPO" status --porcelain -- .SRCINFO)" ]]; then
156+
echo "No PKGBUILD/.SRCINFO changes detected. Already up to date."
157+
exit 0
158+
fi
159+
160+
echo "Updated to ${tag_name}"
161+
echo " pkgver: ${pkgver}"
162+
echo " asset: ${ASSET_NAME}"
163+
echo " sha256: ${sha256}"
164+
165+
if [[ "$DRY_RUN" == "true" ]]; then
166+
git -C "$AUR_REPO" --no-pager diff -- PKGBUILD .SRCINFO || true
167+
exit 0
168+
fi
169+
170+
trap - EXIT
171+
rm -f "$PKGBUILD_BACKUP"
172+
if [[ -n "$SRCINFO_BACKUP" ]]; then
173+
rm -f "$SRCINFO_BACKUP"
174+
fi
175+
176+
git -C "$AUR_REPO" add PKGBUILD
177+
if [[ -f "$AUR_REPO/.SRCINFO" ]]; then
178+
git -C "$AUR_REPO" add .SRCINFO
179+
fi
180+
181+
if git -C "$AUR_REPO" diff --cached --quiet; then
182+
echo "No staged changes to commit."
183+
exit 0
184+
fi
185+
186+
git -C "$AUR_REPO" commit -m "Update to ${tag_name}"
187+
git -C "$AUR_REPO" push "$REMOTE"
188+
189+
echo "Pushed AUR update for ${tag_name}"

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ https://github.com/user-attachments/assets/cbf799bc-9fdd-4402-ab61-b4e31f1e264d
2424

2525

2626
## Features
27-
- Multiple Content Providers (AllAnime, AniNeko, and Animepahe) with ordered fallback and up to 1080p support
27+
- Multiple Content Providers (Senshi, AniNeko, AllAnime, and Animepahe) with ordered fallback and up to 1080p support
2828
- Built-in headless browser to bypass Cloudflare/DDoS-Guard protections
2929
- Stream anime online
3030
- Track anime locally, on AniList, or on MyAnimeList
@@ -395,7 +395,7 @@ If the browser reaches the localhost callback page but curd does not continue au
395395
| `SaveMpvSpeed` | Boolean | `true`, `false` | Retains the playback speed set in MPV for next episode. |
396396
| `SkipFiller` | Boolean | `true`, `false` | Skips filler episodes when supported. |
397397
| `MenuOrder` | String | Comma-separated list | Controls which menu items appear and their order. Available options: `CURRENT`, `ALL`, `UNTRACKED`, `UPDATE`, `REMAP_PROVIDER`, `CONTINUE_LAST`, `PLANNING`, `COMPLETED`, `PAUSED`, `DROPPED`, `REWATCHING`, `TRACKER`, `PROVIDER`. Only listed items will be shown. Default: `CURRENT,ALL,UNTRACKED,UPDATE,REMAP_PROVIDER,CONTINUE_LAST,TRACKER,PROVIDER` |
398-
| `Provider` | List | `["anineko"]`, `["allanime"]`, `["animepahe"]`, `["anineko","allanime"]`, `["allanime","animepahe"]`, `["allanime","no-animepahe"]`, `stacked` | Sets the ordered content-provider fallback list. Curd tries providers from left to right for the requested stream. `stacked` / `all` uses every enabled provider. AllAnime and Animepahe are disabled by default; include them in `Provider` to enable. If the primary provider has no search results or stream and Animepahe is not configured, Curd may ask before enabling Animepahe. Default: `["anineko"]` |
398+
| `Provider` | List | `stacked`, `["anipub"]`, `["anineko"]`, `["allanime"]`, `["animepahe"]` | Sets the content-provider fallback list. `stacked` (default) uses the preferred order: senshi → anipub → anineko → allanime → animepahe. A single-provider list uses only that site. AllAnime and Animepahe are disabled by default unless included in `Provider`. Default: `stacked` |
399399
| `ManualProviderSearch` | Boolean | `true`, `false` | Skip automatic provider matching and always show provider search results for manual selection. Displays a hint with the tracker title, format (TV/Movie/etc.), episode count, and sub/dub mode. Default: `false` |
400400
| `TrackingLocal` | Boolean | `true` | Legacy compatibility flag. Local playback history is always enabled. |
401401
| `TrackingRemote` | Enum | `none`, `anilist`, `myanimelist`, `anilist+myanimelist` | Selects which remote tracker curd syncs with. |
@@ -420,6 +420,8 @@ If the browser reaches the localhost callback page but curd does not continue au
420420
- [MyAnimeList API](https://myanimelist.net/apiconfig/references/api/v2) - MyAnimeList OAuth and tracking sync
421421
- [AniSkip API](https://api.aniskip.com/api-docs) - Get anime intro and outro timings
422422
- [AllAnime Content](https://allanime.to/) - Fetch anime url
423+
- [Senshi Project](https://senshi.live/) - Default provider with direct HLS streams and MAL-based catalog matching
424+
- [AniPub](https://anipub.xyz/) - Fast JSON catalog APIs with MegaPlay HLS streams
423425
- [AniNeko Content](https://anineko.to/) - Alternative provider with soft/hard sub stream selection
424426
- [Animepahe Content](https://animepahe.pw/) - Alternative provider for 1080p streams
425427
- [Jikan](https://jikan.moe/) - Get filler episode number

cmd/curd/main.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ func main() {
4848
fmt.Println("Error loading config:", err)
4949
return
5050
}
51+
internal.SetCurdVersion(resolvedVersion())
52+
if updated, migrateErr := internal.MigrateOnVersionUpgrade(configFilePath, &userCurdConfig, resolvedVersion()); migrateErr != nil {
53+
fmt.Println("Error applying storage migration:", migrateErr)
54+
return
55+
} else if updated {
56+
fmt.Println("Updated provider settings to the current default fallback stack.")
57+
}
5158
internal.SetGlobalConfig(&userCurdConfig)
5259

5360
logFile := filepath.Join(os.ExpandEnv(userCurdConfig.StoragePath), "debug.log")
@@ -177,6 +184,7 @@ func main() {
177184

178185
// Setup screen for interactive mode (only if not changing token)
179186
internal.ClearScreen()
187+
internal.InstallTerminalInterruptHandler()
180188
defer internal.RestoreScreen()
181189

182190
// Set SubOrDub based on the flags

docs/providers.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,9 @@ Document the decision in a short ADR before building. The compile-time registry
327327

328328
| Provider | Package | Notes |
329329
|----------|---------|-------|
330+
| Senshi | `internal/providers/senshi` | REST search/episodes, direct HLS from `/episode-embeds`, MAL id keys and `/posters/{mal_id}.webp` thumbnails |
331+
| AniPub | `internal/providers/anipub` | JSON search/info/details APIs, MegaPlay embed resolution via `/stream/getSources`, MAL id in `ExtraData` for tracker matching |
332+
| AniNeko | `internal/providers/anineko` | AJAX search, HTML scrape, bibiemb/vibeplayer embed resolution, `SubStyle` / `HintResolver` |
330333
| AllAnime | `internal/providers/allanime` | GraphQL search/episodes, parallel stream resolution, `HintResolver` |
331334
| Animepahe | `internal/providers/animepahe` | DDoS-Guard + rod browser, `IDResolver`, `DefaultDisabled`, `OptOutToken` |
332335

internal/config.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ func defaultConfigMap() map[string]string {
105105
"AlternateScreen": "true",
106106
"DiscordPresence": "true",
107107
"DiscordClientId": "1287457464148820089",
108-
"Provider": "[\"anineko\"]",
108+
"Provider": "stacked",
109109
"DisabledProviders": "[]",
110110
"ManualProviderSearch": "false",
111111
"TrackingLocal": "true",

internal/curd.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -157,12 +157,12 @@ func ExitCurd(err error) {
157157
fmt.Println("Press Enter to exit")
158158
var wait string
159159
fmt.Scanln(&wait)
160-
os.Exit(1)
160+
exitWithRestore(1)
161161
} else {
162-
os.Exit(1)
162+
exitWithRestore(1)
163163
}
164164
}
165-
os.Exit(0)
165+
exitWithRestore(0)
166166
}
167167

168168
func CurdOut(data interface{}) {
@@ -983,9 +983,9 @@ func SetupCurd(userCurdConfig *CurdConfig, anime *Anime, user *User, databaseAni
983983
needsProviderSearch := false
984984
if animePointer == nil {
985985
needsProviderSearch = true
986-
} else if animePointer.ProviderName != "" && !ProviderStackContains(userCurdConfig, animePointer.ProviderName) {
986+
} else if animePointer.ProviderId == "" {
987987
needsProviderSearch = true
988-
} else if animePointer.ProviderName == "" && !ProviderStackContains(userCurdConfig, "allanime") {
988+
} else if animePointer.ProviderName != "" && !ProviderStackContains(userCurdConfig, animePointer.ProviderName) {
989989
needsProviderSearch = true
990990
}
991991

@@ -1296,17 +1296,17 @@ func StartCurd(userCurdConfig *CurdConfig, anime *Anime) string {
12961296
if err := resolveRuntimeProviderID(userCurdConfig, anime); err != nil {
12971297
Log(fmt.Sprintf("Failed to resolve provider id: %v", err))
12981298
CurdOut("Failed to resolve anime provider id: " + err.Error())
1299-
os.Exit(1)
1299+
exitWithRestore(1)
13001300
}
13011301

13021302
// Validate inputs
13031303
if anime.ProviderId == "" {
13041304
CurdOut("Error: No anime ID found")
1305-
os.Exit(1)
1305+
exitWithRestore(1)
13061306
}
13071307
if anime.Ep.Number <= 0 {
13081308
CurdOut("Error: Invalid episode number")
1309-
os.Exit(1)
1309+
exitWithRestore(1)
13101310
}
13111311

13121312
if (anime.Ep.NextEpisode.Number == anime.Ep.Number) && (len(anime.Ep.NextEpisode.Links) > 0) {
@@ -1477,7 +1477,7 @@ episodeLinksReady:
14771477

14781478
if err != nil {
14791479
Log("Failed to start mpv")
1480-
os.Exit(1)
1480+
exitWithRestore(1)
14811481
}
14821482

14831483
return mpvSocketPath
@@ -2148,6 +2148,6 @@ func ChangeProvider(userCurdConfig *CurdConfig) {
21482148
SaveConfigToFile(configPath, configMap)
21492149
}
21502150

2151-
CurdOut(fmt.Sprintf("\nProvider successfully changed to %s.\n", selected.Label))
2151+
CurdOut(fmt.Sprintf("\nProvider successfully changed to %s.\n", providerConfigDisplayLabel(userCurdConfig.Provider)))
21522152
time.Sleep(1 * time.Second)
21532153
}

internal/loadproviders/load.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,6 @@ import (
55
_ "github.com/wraient/curd/internal/providers/allanime"
66
_ "github.com/wraient/curd/internal/providers/animepahe"
77
_ "github.com/wraient/curd/internal/providers/anineko"
8+
_ "github.com/wraient/curd/internal/providers/anipub"
9+
_ "github.com/wraient/curd/internal/providers/senshi"
810
)

internal/localTracking.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -498,7 +498,7 @@ func WatchUntracked(userCurdConfig *CurdConfig) {
498498
mpvSocketPath, err := StartVideo(PrioritizeLink(link), []string{}, fmt.Sprintf("%s - Episode %d", GetAnimeName(anime), anime.Ep.Number), &anime)
499499
if err != nil {
500500
Log("Failed to start mpv")
501-
os.Exit(1)
501+
exitWithRestore(1)
502502
}
503503

504504
anime.Ep.Player.SocketPath = mpvSocketPath

internal/logic_regression_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -219,8 +219,8 @@ func TestGetProviderNormalizesConfiguredProviderName(t *testing.T) {
219219
CurrentProvider = nil
220220
SetGlobalConfig(&CurdConfig{Provider: " AllAnime "})
221221

222-
if got := GetProvider().Name(); got != "anineko" {
223-
t.Fatalf("expected anineko provider when allanime is disabled, got %q", got)
222+
if got := GetProvider().Name(); got != "senshi" {
223+
t.Fatalf("expected senshi provider when allanime is disabled, got %q", got)
224224
}
225225
}
226226

@@ -235,8 +235,8 @@ func TestGetProviderFallsBackWhenConfiguredProviderDisabled(t *testing.T) {
235235
CurrentProvider = nil
236236
SetGlobalConfig(&CurdConfig{Provider: " AnimePahe "})
237237

238-
if got := GetProvider().Name(); got != "anineko" {
239-
t.Fatalf("expected anineko fallback for disabled provider, got %q", got)
238+
if got := GetProvider().Name(); got != "senshi" {
239+
t.Fatalf("expected senshi fallback for disabled provider, got %q", got)
240240
}
241241
}
242242

internal/provider.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ func parseProviderConfigParts(rawProvider string) []string {
5858
func parseProviderConfig(rawProvider string) ([]string, bool) {
5959
rawProvider = strings.TrimSpace(rawProvider)
6060
if rawProvider == "" {
61-
rawProvider = firstEnabledProviderName()
61+
return defaultEnabledProviderStack(), false
6262
}
6363

6464
normalizedRaw := strings.ToLower(rawProvider)
@@ -108,7 +108,7 @@ func parseProviderConfig(rawProvider string) ([]string, bool) {
108108
}
109109

110110
func configuredProviderNames(config *CurdConfig) []string {
111-
rawProvider := firstEnabledProviderName()
111+
rawProvider := stackedProviderConfigValue
112112
if config != nil && strings.TrimSpace(config.Provider) != "" {
113113
rawProvider = config.Provider
114114
}
@@ -122,7 +122,15 @@ func ConfiguredProviderNames(config *CurdConfig) []string {
122122
}
123123

124124
func canonicalProviderConfigValue(rawProvider string) string {
125+
rawProvider = strings.TrimSpace(rawProvider)
126+
if isStackedProviderConfig(rawProvider) {
127+
return stackedProviderConfigValue
128+
}
129+
125130
names, animepaheDeclined := parseProviderConfig(rawProvider)
131+
if !animepaheDeclined && providerListsEqual(names, defaultEnabledProviderStack()) {
132+
return stackedProviderConfigValue
133+
}
126134
return formatProviderConfigValue(names, animepaheDeclined)
127135
}
128136

0 commit comments

Comments
 (0)