diff --git a/.dockerignore b/.dockerignore index fd29dba..30d4444 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,6 @@ .git/ .gitignore -node_modules/ +**/node_modules/ .env .env.* .vscode/ @@ -26,6 +26,13 @@ docs/swagger/swagger.yaml src/tests/ **/*_test.go +# Lock files and config files not needed in build context +*.lock +tsconfig*.json +.eslintrc* +.prettierrc* +vitest.config.* + # NOTE: do not broaden this to src/internal/frontend/dist — Dockerfile.build.{amd64,arm64, # windows} expect the frontend to be ALREADY BUILT there and copied in with the context. # Only docker/Dockerfile builds it in its own stage. diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000..d6feeb1 --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,31 @@ + +name: Dependabot auto-merge + +on: + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: write + pull-requests: write + +jobs: + automerge: + if: github.event.pull_request.user.login == 'dependabot[bot]' + runs-on: ubuntu-latest + + steps: + - name: Fetch Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Enable auto-merge + if: | + steps.metadata.outputs.update-type == 'version-update:semver-patch' || + steps.metadata.outputs.update-type == 'version-update:semver-minor' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_URL: ${{ github.event.pull_request.html_url }} + run: gh pr merge --auto --squash "$PR_URL" diff --git a/.gitignore b/.gitignore index 5ca30be..c49df3f 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,5 @@ daemon.log # Playwright output **/test-results/* +# Markdownlint config +.markdownlint.json diff --git a/docs/agents/config.md b/docs/agents/config.md index e413344..3098b1b 100644 --- a/docs/agents/config.md +++ b/docs/agents/config.md @@ -35,6 +35,7 @@ Struct defined in `src/internal/files/filemanager.go`. Defaults set in `getDefau | `Notifications.Webhooks[].Method` | `method` | `string` | — | HTTP method (`POST`/`GET`/`PUT`) | | `Notifications.Webhooks[].Headers` | `headers` | `map[string]string` | — | Request headers — values support `{{vars}}` | | `Notifications.Webhooks[].Body` | `body` | `string` | — | Request body — supports `{{vars}}` | +| `Notifications.Webhooks[].Language` | `language` | `string` | — | Locale used to render the built-in `{{title}}`/`{{message}}` when the body does not supply its own. Empty = keep the daemon default (pt-BR), so a config written by an older release keeps firing in Portuguese exactly as before. Supported: `pt-BR`, `en` | | `Notifications.BatchWindowSeconds` | `notifications.batch_window_seconds` | `int` | `60` | Agrupa os eventos de uma mesma janela num webhook só (uma fila **por evento**, nunca misturando sucesso com falha). `0` desliga: um webhook por evento, comportamento original. Um `config.json` anterior ao campo carrega com **60**, não com `0`: `LoadConfigs` desserializa por cima de `getDefaultConfig()`. Os 60s são medidos, não gosto — num backfill real de 67 organizes, janela de 10s entregaria 53 webhooks (e o serviço respondeu 429) e a de 60s entrega 24. Com um item na janela a mensagem é idêntica à não-agrupada; com N > 1, `{{title}}` ganha a contagem, `{{message}}` vira N linhas, `{{count}}` traz N, e `{{anime_name}}`/`{{episode}}`/`{{reason}}` ficam vazios (não existe valor único). Ver decisions.md #47 | | `Priorities` | `priorities` | `nyaa.Priorities` | see below | Ordered lists driving torrent ranking/filtering. Defined in `src/internal/nyaa/priorities.go` | | `Priorities.CriteriaOrder` | `priorities.criteria_order` | `[]string` | `["uncensored","source","resolution","health","codec","fansub","audio","size"]` | Order in which sort criteria are applied. `SortTorrentResults` only uses the episode-relevant subset (`uncensored`, `resolution`, `codec`, `health`, `fansub`, `size` — everything but `source`); `SortMovieResults` uses all. `health` sits **before** `fansub` on purpose — see [decisions.md #55](decisions.md#55-health-compara-faixa-de-seeders-e-vem-antes-de-fansub). **An existing `config.json` keeps the order it already has** (`LoadConfigs` unmarshals over the defaults), so to pick up the new order either edit `criteria_order` by hand or hit "restore defaults" on the Priorities page | diff --git a/scripts/build.sh b/scripts/build.sh index 0a338c3..3c63cf8 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -52,9 +52,14 @@ fi mkdir -p "$BUILD_DIR" # The Dockerfiles embed src/internal/frontend/dist; CI builds it in its own step. +# Skip frontend rebuild if dist already exists to save time on local builds. if [ -z "$CI" ] && [ -z "$GITHUB_ACTIONS" ]; then - echo -e "${YELLOW}Building frontend...${NC}" - (cd src/internal/frontend && bun install --frozen-lockfile && bun run build) + if [ -d "src/internal/frontend/dist" ] && [ "$(ls -A src/internal/frontend/dist 2>/dev/null | wc -l)" -gt 0 ]; then + echo -e "${YELLOW}Frontend already built, skipping...${NC}" + else + echo -e "${YELLOW}Building frontend...${NC}" + (cd src/internal/frontend && bun install --frozen-lockfile && bun run build) + fi fi _has_buildx() { diff --git a/src/cmd/cli/main.go b/src/cmd/cli/main.go index 93d6beb..f5934df 100644 --- a/src/cmd/cli/main.go +++ b/src/cmd/cli/main.go @@ -25,11 +25,13 @@ import ( "bufio" "encoding/json" "fmt" + "io" "os" "os/exec" "path/filepath" "runtime" "sort" + "strconv" "strings" "time" @@ -155,8 +157,104 @@ func main() { { Name: "episodes", Usage: "List downloaded episodes", - Action: func(c *cli.Context) error { - return handleEpisodes() + Subcommands: []*cli.Command{ + { + Name: "list", + Usage: "List downloaded episodes", + Action: func(c *cli.Context) error { + return handleEpisodes() + }, + }, + { + Name: "delete", + Usage: "Delete episodes from disk and from the library", + ArgsUsage: " [ ...]", + Description: "Removes the episode files, the hardlinks in the library, the torrent\n" + + "(with --keep-data to leave the files), and the record in downloaded_episodes.\n" + + "Same call the web UI makes when you delete one or more rows.", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "keep-data", + Usage: "Delete the torrent but leave the downloaded files on disk", + }, + &cli.BoolFlag{ + Name: "block", + Usage: "Also block the episode so the daemon does not re-download it", + }, + }, + Action: func(c *cli.Context) error { + return handleEpisodeDelete(c) + }, + }, + { + Name: "redownload", + Usage: "Re-download an episode that is already on disk", + ArgsUsage: " [ ...]", + Description: "Drops the torrent and the record, then queues a fresh download.\n" + + "Same call the web UI makes when you click re-download.", + Action: func(c *cli.Context) error { + return handleEpisodeRedownload(c) + }, + }, + }, + }, + { + Name: "standalones", + Usage: "Manage standalone (not-on-AniList) anime tracking", + Subcommands: []*cli.Command{ + { + Name: "list", + Usage: "List tracked standalone media ids", + Action: func(c *cli.Context) error { + return handleStandalonesList() + }, + }, + { + Name: "add", + Usage: "Start tracking a standalone anime and download what is available", + ArgsUsage: "", + Action: func(c *cli.Context) error { + return handleStandalonesAdd(c) + }, + }, + { + Name: "remove", + Usage: "Stop tracking a standalone anime", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "delete-episodes", + Usage: "Also delete the downloaded episode files", + }, + }, + Action: func(c *cli.Context) error { + return handleStandalonesRemove(c) + }, + }, + { + Name: "export", + Usage: "Print the tracked media ids as JSON", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "file", + Usage: "Write to this file instead of stdout", + Value: "", + }, + }, + Action: func(c *cli.Context) error { + return handleStandalonesExport(c) + }, + }, + { + Name: "import", + Usage: "Replace the tracked list with the media ids from a file (or stdin)", + ArgsUsage: "[file]", + Description: "Reads a JSON int-list, deduplicates and writes it back. The file\n" + + "is the output of `standalones export`, so migrating a setup is one pipe.", + Action: func(c *cli.Context) error { + return handleStandalonesImport(c) + }, + }, }, }, { @@ -185,6 +283,20 @@ func main() { return handleLogs(c.Int("lines"), c.String("level"), c.String("search")) }, }, + { + Name: "debug", + Usage: "Reproduce the daemon's search/match pipeline for one anime without downloading", + ArgsUsage: "", + Description: "Runs the same search, pack-axis and selection logic the daemon runs for\n" + + "this anime, logs every Nyaa row and which ones matched, and writes the machine-\n" + + "readable summary to /.debug__N/summary.json. Nothing is\n" + + "downloaded and nothing in downloaded_episodes is touched — the existing\n" + + "scripts/robustness-animes.txt workflow, which used to require building the daemon\n" + + "directly, is now reachable from the CLI.", + Action: func(c *cli.Context) error { + return handleDebug(c) + }, + }, { Name: "open", Usage: "Open the web UI in the browser", @@ -297,21 +409,69 @@ func handleConfigGet() error { if outputJSON { outputJSONResponse(config) } else { + // Every key the daemon serves, rendered as a flat table. The old hand-written list + // covered 7 of ~25 and read as "the other fields don't exist" — the docs claim `config get` + // is the authoritative key list, so it has to actually list them. The order is the struct + // field order, which is also the order `config set` accepts (it is case/underscore + // insensitive), so scanning the output top-to-bottom gives the valid spellings. + raw, err := json.Marshal(config) + if err != nil { + return fmt.Errorf("failed to encode config: %w", err) + } + var fields map[string]any + if err := json.Unmarshal(raw, &fields); err != nil { + return fmt.Errorf("failed to decode config: %w", err) + } + t := table.NewWriter() t.SetOutputMirror(os.Stdout) - t.AppendHeader(table.Row{"Field", "Value"}) - t.AppendRow(table.Row{"Anilist Usernames", strings.Join(config.AnilistUsernames, ", ")}) - t.AppendRow(table.Row{"Completed Anime Path", config.CompletedAnimePath}) - t.AppendRow(table.Row{"Check Interval", fmt.Sprintf("%d minutes", config.CheckInterval)}) - t.AppendRow(table.Row{"Max Episodes Per Anime", config.MaxEpisodesPerAnime}) - t.AppendRow(table.Row{"Episode Retry Limit", config.EpisodeRetryLimit}) - t.AppendRow(table.Row{"Delete Watched Episodes", config.DeleteWatchedEpisodes}) - t.AppendRow(table.Row{"Excluded Lists", strings.Join(config.ExcludedLists, ", ")}) + t.AppendHeader(table.Row{"Key", "Value"}) + for _, name := range sortedJSONKeys(fields) { + t.AppendRow(table.Row{name, formatConfigValue(fields[name])}) + } t.Render() } return nil } +// sortedJSONKeys returns the JSON field names of a config in struct-declared order. json.Unmarshal +// preserves insertion order only when decoding into a map[string]any built by encoding a struct +// (the encoder writes fields in declaration order), so sorting alphabetically would lose the +// mental model of "top of the file = top of the table". +func sortedJSONKeys(fields map[string]any) []string { + keys := make([]string, 0, len(fields)) + for k := range fields { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// formatConfigValue turns a decoded JSON value into the one-line form `config set` expects: lists +// comma-separated, bools as true/false, numbers plain. It mirrors parseConfigValue's list branch so +// `config get` and `config set` round-trip. +func formatConfigValue(v any) string { + switch val := v.(type) { + case []any: + items := make([]string, 0, len(val)) + for _, item := range val { + if s, ok := item.(string); ok { + items = append(items, s) + } else { + b, _ := json.Marshal(item) + items = append(items, string(b)) + } + } + return strings.Join(items, ", ") + case bool: + return strconv.FormatBool(val) + case nil: + return "" + default: + return fmt.Sprintf("%v", val) + } +} + // handleConfigSet patches ONE key of config.json by its JSON name, without the CLI knowing the // field list: it round-trips the config through a map, so every key the daemon serves is settable // and a new field needs no change here. The old hand-written switch covered 7 of the 25 keys and @@ -319,6 +479,11 @@ func handleConfigGet() error { // // The saved value is also the type oracle: a key that already holds a list accepts the // comma-separated form, everything else is JSON-parsed and falls back to a plain string. +// +// Dotted paths reach into nested objects — `notifications.batch_window_seconds 30` and +// `priorities.fansubs 1080p,1080p-raw` set a field inside a sub-object without the CLI needing to +// know the shape. A segment that does not exist (typo, or a field the daemon stopped serving) is +// an "unknown key" error, same as a bad top-level one. func handleConfigSet(key, value string) error { client := getClient() config, err := client.GetConfig() @@ -335,13 +500,13 @@ func handleConfigSet(key, value string) error { return fmt.Errorf("failed to decode current config: %w", err) } - name, ok := matchConfigKey(fields, key) - if !ok { - return fmt.Errorf("unknown config key: %s (run `config get` to see the keys)", key) + target, err := resolveDotted(fields, key) + if err != nil { + return err } - fields[name] = parseConfigValue(fields[name], value) + target.set(parseConfigValue(target.current, value)) - patched, err := json.Marshal(fields) + patched, err := json.Marshal(target.root) if err != nil { return fmt.Errorf("failed to encode config: %w", err) } @@ -357,11 +522,81 @@ func handleConfigSet(key, value string) error { if outputJSON { outputJSONResponse(map[string]string{"message": "Configuration updated"}) } else { - fmt.Printf("Configuration updated: %s = %s\n", name, value) + fmt.Printf("Configuration updated: %s = %s\n", key, value) } return nil } +// configTarget is the leaf of a dotted path: the map it lives in, the key under which it is +// stored, and the value currently there (used only as the type oracle for lists). +type configTarget struct { + root map[string]any + key string + current any +} + +// set writes v under t.key into t.root, creating the map if it was nil (a brand-new nested object +// is a valid write — e.g. adding the first webhook to an empty notifications.webhooks list is a +// list mutation, not a map creation, but `notifications.new_key foo` is a real first-time field). +func (t *configTarget) set(v any) { + if t.root == nil { + t.root = map[string]any{} + } + t.root[t.key] = v +} + +// resolveDotted walks `key` into fields. A key without dots is matched case/underscore-insensitively +// against the top-level JSON names (matchConfigKey). A dotted key splits on the first dot only for +// the top-level match, then recurses: `notifications.batch_window_seconds` → fields["notifications"] +// → ["batch_window_seconds"]. Each segment is matched case/underscore-insensitively against the +// keys of the object it lands in, so `Max_Search_Pages` and `maxSearchPages` resolve identically. +// +// The leaf's container map is returned so the caller can mutate it in place; the root is the same +// map the whole walk started from, so one Marshal round-trips the whole change. +func resolveDotted(fields map[string]any, key string) (*configTarget, error) { + dot := strings.IndexByte(key, '.') + if dot < 0 { + name, ok := matchConfigKey(fields, key) + if !ok { + return nil, fmt.Errorf("unknown config key: %s (run `config get` to see the keys)", key) + } + return &configTarget{root: fields, key: name, current: fields[name]}, nil + } + + head := key[:dot] + rest := key[dot+1:] + name, ok := matchConfigKey(fields, head) + if !ok { + return nil, fmt.Errorf("unknown config key: %s (run `config get` to see the keys)", key) + } + return resolveDottedNested(fields, name, rest) +} + +// resolveDottedNested walks the remaining dotted path into the sub-object reached by `name`. It +// does not re-match `name` — that was already resolved by the caller — it only descends. +func resolveDottedNested(root map[string]any, name, rest string) (*configTarget, error) { + cur := root[name] + m, ok := cur.(map[string]any) + if !ok { + return nil, fmt.Errorf("unknown config key: %s (not an object)", name) + } + if strings.IndexByte(rest, '.') < 0 { + leaf, ok := matchConfigKey(m, rest) + if !ok { + return nil, fmt.Errorf("unknown config key: %s (run `config get` to see the keys)", name+"."+rest) + } + return &configTarget{root: m, key: leaf, current: m[leaf]}, nil + } + dot := strings.IndexByte(rest, '.') + head := rest[:dot] + rest = rest[dot+1:] + leaf, ok := matchConfigKey(m, head) + if !ok { + return nil, fmt.Errorf("unknown config key: %s (run `config get` to see the keys)", name+"."+head) + } + return resolveDottedNested(m, leaf, rest) +} + // matchConfigKey resolves a user-typed key to the real JSON name, ignoring case and underscores so // `maxSearchPages`, `max_search_pages` and `MAX_SEARCH_PAGES` all land on the same field. func matchConfigKey(fields map[string]any, key string) (string, bool) { @@ -735,3 +970,249 @@ func outputJSONResponse(data interface{}) { fmt.Fprintf(os.Stderr, "Error encoding JSON: %v\n", err) } } + +// --- episodes --- + +func handleEpisodeDelete(c *cli.Context) error { + args := c.Args() + if args.Len() < 2 { + return fmt.Errorf("usage: episodes delete [ ...]") + } + animeID, err := strconv.Atoi(args.Get(0)) + if err != nil || animeID <= 0 { + return fmt.Errorf("invalid anime id: %s", args.Get(0)) + } + + var episodes []int + for _, a := range args.Slice()[1:] { + n, err := strconv.Atoi(a) + if err != nil || n <= 0 { + return fmt.Errorf("invalid episode number: %s", a) + } + episodes = append(episodes, n) + } + + client := getClient() + count, err := client.BatchDeleteEpisodes(animeID, episodes, c.Bool("keep-data"), c.Bool("block")) + if err != nil { + return err + } + + if outputJSON { + outputJSONResponse(map[string]any{"message": "Episodes deleted", "anime_id": animeID, "episodes": episodes, "count": count}) + } else { + fmt.Printf("Deleted %d episode(s) of anime %d\n", count, animeID) + } + return nil +} + +func handleEpisodeRedownload(c *cli.Context) error { + args := c.Args() + if args.Len() < 2 { + return fmt.Errorf("usage: episodes redownload [ ...]") + } + animeID, err := strconv.Atoi(args.Get(0)) + if err != nil || animeID <= 0 { + return fmt.Errorf("invalid anime id: %s", args.Get(0)) + } + + var episodes []int + for _, a := range args.Slice()[1:] { + n, err := strconv.Atoi(a) + if err != nil || n <= 0 { + return fmt.Errorf("invalid episode number: %s", a) + } + episodes = append(episodes, n) + } + + client := getClient() + count, err := client.BatchRedownloadEpisodes(animeID, episodes) + if err != nil { + return err + } + + if outputJSON { + outputJSONResponse(map[string]any{"message": "Episodes redownload started", "anime_id": animeID, "episodes": episodes, "count": count}) + } else { + fmt.Printf("Re-downloading %d episode(s) of anime %d\n", count, animeID) + } + return nil +} + +// --- standalones --- + +func handleStandalonesList() error { + client := getClient() + ids, err := client.LoadStandaloneAnimes() + if err != nil { + return err + } + + if outputJSON { + outputJSONResponse(ids) + return nil + } + + if len(ids) == 0 { + fmt.Println("No standalone animes tracked") + return nil + } + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Media ID"}) + for _, id := range ids { + t.AppendRow(table.Row{id}) + } + t.Render() + return nil +} + +func handleStandalonesAdd(c *cli.Context) error { + if c.NArg() != 1 { + return fmt.Errorf("usage: standalones add ") + } + mediaID, err := strconv.Atoi(c.Args().Get(0)) + if err != nil || mediaID <= 0 { + return fmt.Errorf("invalid media id: %s", c.Args().Get(0)) + } + + client := getClient() + added, err := client.AddStandaloneAnime(mediaID) + if err != nil { + return err + } + + if outputJSON { + outputJSONResponse(map[string]any{"media_id": mediaID, "added": added}) + } else { + fmt.Printf("Standalone anime %d tracked; %d episode(s) queued\n", mediaID, added) + } + return nil +} + +func handleStandalonesRemove(c *cli.Context) error { + if c.NArg() != 1 { + return fmt.Errorf("usage: standalones remove ") + } + mediaID, err := strconv.Atoi(c.Args().Get(0)) + if err != nil || mediaID <= 0 { + return fmt.Errorf("invalid media id: %s", c.Args().Get(0)) + } + + client := getClient() + if err := client.RemoveStandaloneAnime(mediaID, c.Bool("delete-episodes")); err != nil { + return err + } + + if outputJSON { + outputJSONResponse(map[string]any{"media_id": mediaID, "deleted_episodes": c.Bool("delete-episodes")}) + } else { + fmt.Printf("Standalone anime %d untracked\n", mediaID) + } + return nil +} + +func handleStandalonesExport(c *cli.Context) error { + client := getClient() + ids, err := client.LoadStandaloneAnimes() + if err != nil { + return err + } + + file := c.String("file") + if file == "" { + if outputJSON { + outputJSONResponse(ids) + } else { + b, _ := json.Marshal(ids) + fmt.Println(string(b)) + } + return nil + } + + // Pretty-printed JSON, matching the on-disk format SaveStandaloneAnimes writes, so the file + // is directly importable back without a re-format step. + b, err := json.MarshalIndent(ids, "", " ") + if err != nil { + return err + } + b = append(b, '\n') + if err := os.WriteFile(file, b, 0644); err != nil { + return fmt.Errorf("failed to write %s: %w", file, err) + } + fmt.Printf("Exported %d standalone animes to %s\n", len(ids), file) + return nil +} + +func handleStandalonesImport(c *cli.Context) error { + var data []byte + var source string + if c.NArg() == 0 { + data, _ = io.ReadAll(os.Stdin) + source = "stdin" + } else { + source = c.Args().Get(0) + var err error + data, err = os.ReadFile(source) + if err != nil { + return fmt.Errorf("failed to read %s: %w", source, err) + } + } + + var ids []int + if err := json.Unmarshal(data, &ids); err != nil { + return fmt.Errorf("input is not a JSON int-list: %w", err) + } + for _, id := range ids { + if id <= 0 { + return fmt.Errorf("invalid media id %d: ids must be positive", id) + } + } + + client := getClient() + if err := client.SaveStandaloneAnimes(ids); err != nil { + return err + } + + if outputJSON { + outputJSONResponse(map[string]any{"source": source, "count": len(ids)}) + } else { + fmt.Printf("Imported %d standalone animes from %s\n", len(ids), source) + } + return nil +} + +// --- debug --- + +func handleDebug(c *cli.Context) error { + if c.NArg() != 1 { + return fmt.Errorf("usage: debug ") + } + animeID, err := strconv.Atoi(c.Args().Get(0)) + if err != nil || animeID <= 0 { + return fmt.Errorf("invalid anime id: %s", c.Args().Get(0)) + } + + client := getClient() + summary, err := client.RunAnimeDebug(animeID) + if err != nil { + return err + } + + if outputJSON { + outputJSONResponse(summary) + } else { + fmt.Printf("Anime: %s (id %d)\n", summary.AnimeName, summary.AnimeID) + would := 0 + magnets := 0 + for _, ep := range summary.Episodes { + if ep.WouldSearch { + would++ + magnets += ep.MagnetsFound + } + } + fmt.Printf("Episodes: %d total, %d would search, %d magnets found\n", + len(summary.Episodes), would, magnets) + } + return nil +} diff --git a/src/cmd/daemon/main.go b/src/cmd/daemon/main.go index b0229f6..5ec0251 100644 --- a/src/cmd/daemon/main.go +++ b/src/cmd/daemon/main.go @@ -216,6 +216,7 @@ func ensureStartupSession(manager *torrents.SessionManager, fileManager *files.F // Antes do Ensure: a sessao nova roda a adocao da fila assim que nasce, e ela precisa // ja saber o limite para nao promover tudo o que a rain reabriu parado. manager.SetMaxActiveDownloads(configs.MaxConcurrentDownloads) + manager.SetDisableSeeding(configs.DisableSeeding) downloadPath := configs.DownloadPath() if _, err := manager.Ensure(downloadPath); err != nil { logger.Logger.Error().Err(err).Str("download_path", downloadPath).Msg("Failed to create the embedded torrent session at startup; the verification pass will retry") diff --git a/src/internal/anilist/anilist.go b/src/internal/anilist/anilist.go index efe6c18..ac31820 100644 --- a/src/internal/anilist/anilist.go +++ b/src/internal/anilist/anilist.go @@ -192,10 +192,15 @@ type AniListResponse struct { Data struct { Page struct { MediaList []MediaList `json:"mediaList"` + PageInfo PageInfo `json:"pageInfo"` } `json:"Page"` } `json:"data"` } +type PageInfo struct { + HasNextPage bool `json:"hasNextPage"` +} + type MediaListStatus string const ( @@ -489,8 +494,8 @@ func GetCustomListsMap(userName string, statuses []string, priority Priority) ma func GetAllCurrentAnime(userName string, statuses []string) (*AniListResponse, error) { query := ` - query GetAllCurrentAnime($userName: String, $type: MediaType, $statuses: [MediaListStatus]) { - Page { + query GetAllCurrentAnime($userName: String, $type: MediaType, $statuses: [MediaListStatus], $page: Int, $perPage: Int) { + Page(page: $page, perPage: $perPage) { mediaList(userName: $userName, type: $type, status_in: $statuses) { id status @@ -535,6 +540,9 @@ func GetAllCurrentAnime(userName string, statuses []string) (*AniListResponse, e } } } + pageInfo { + hasNextPage + } } } ` @@ -543,23 +551,33 @@ func GetAllCurrentAnime(userName string, statuses []string) (*AniListResponse, e return &AniListResponse{}, nil } - variables := RequestVariables{ - "userName": userName, - "type": "ANIME", - "statuses": statuses, - } - key := userName + "\x00" + strings.Join(statuses, "\x00") - resp, err := sendAnilistRequest[AniListResponse](query, variables, PriorityCritical) - if err != nil { - if stale, ok := staleList(passListCache, key); ok { - return mediaListResponse(stale), ErrFromCache + var allMediaList []MediaList + page := 1 + perPage := 500 // AniList maximo: 500 entries por pagina + for { + variables := RequestVariables{ + "userName": userName, + "type": "ANIME", + "statuses": statuses, + "page": page, + "perPage": perPage, } - return nil, err + resp, err := sendAnilistRequest[AniListResponse](query, variables, PriorityCritical) + if err != nil { + if stale, ok := staleList(passListCache, key); ok { + return mediaListResponse(stale), ErrFromCache + } + return nil, err + } + allMediaList = append(allMediaList, resp.Data.Page.MediaList...) + if !resp.Data.Page.PageInfo.HasNextPage { + break + } + page++ } - - storeList(passListCache, key, resp.Data.Page.MediaList, 0) - return resp, nil + storeList(passListCache, key, allMediaList, 0) + return mediaListResponse(allMediaList), nil } // GetFrontendAnimeList alimenta /api/v1/animes, que o frontend faz poll a cada 30s por aba. @@ -569,8 +587,8 @@ func GetAllCurrentAnime(userName string, statuses []string) (*AniListResponse, e // atraso na tela e barato perto de derrubar o daemon. func GetFrontendAnimeList(userName string, statuses []string) (*AniListResponse, error) { query := ` - query GetFrontendAnimeList($userName: String, $type: MediaType, $statuses: [MediaListStatus]) { - Page { + query GetFrontendAnimeList($userName: String, $type: MediaType, $statuses: [MediaListStatus], $page: Int, $perPage: Int) { + Page(page: $page, perPage: $perPage) { mediaList(userName: $userName, type: $type, status_in: $statuses) { id progress @@ -602,6 +620,9 @@ func GetFrontendAnimeList(userName string, statuses []string) (*AniListResponse, } } } + pageInfo { + hasNextPage + } } } ` @@ -615,35 +636,35 @@ func GetFrontendAnimeList(userName string, statuses []string) (*AniListResponse, return mediaListResponse(list), nil } - variables := RequestVariables{ - "userName": userName, - "type": "ANIME", - "statuses": statuses, - } - - resp, err := sendAnilistRequest[AniListResponse](query, variables, PriorityDisposable) - if err != nil { - if stale, ok := staleList(frontendListCache, key); ok { - // Recusado pelo gate de orcamento: esta lista muda quando o usuario mexe na - // AniList, e nao a cada 30s. Servir a leitura vencida deixa a tela funcionando e - // devolve o balde para o passe do daemon, que e o unico que nao pode ser adiado. - // Ver decisions.md #72. Sem erro de proposito: a recusa foi NOSSA, o dado tem - // segundos de idade e o proximo poll ja volta fresco — a tela nao ganha nada - // sabendo disso. - if errors.Is(err, ErrBudgetLow) { - return mediaListResponse(stale), nil + var allMediaList []MediaList + page := 1 + perPage := 500 + for { + variables := RequestVariables{ + "userName": userName, + "type": "ANIME", + "statuses": statuses, + "page": page, + "perPage": perPage, + } + resp, err := sendAnilistRequest[AniListResponse](query, variables, PriorityDisposable) + if err != nil { + if stale, ok := staleList(frontendListCache, key); ok { + if errors.Is(err, ErrBudgetLow) { + return mediaListResponse(stale), nil + } + return mediaListResponse(stale), ErrFromCache } - // Qualquer outra falha e a AniList fora do ar, e a idade do dado passa a ser - // desconhecida (pode vir do snapshot em disco, de dias atras). Aqui o chamador TEM - // de saber: /animes usa para nao disparar o refresh de orfaos contra uma API que - // nao responde. - return mediaListResponse(stale), ErrFromCache + return nil, err } - return nil, err + allMediaList = append(allMediaList, resp.Data.Page.MediaList...) + if !resp.Data.Page.PageInfo.HasNextPage { + break + } + page++ } - - storeList(frontendListCache, key, resp.Data.Page.MediaList, frontendListTTL) - return mediaListResponse(resp.Data.Page.MediaList), nil + storeList(frontendListCache, key, allMediaList, frontendListTTL) + return mediaListResponse(allMediaList), nil } // mediaListResponse embrulha uma lista num AniListResponse. Nasceu para GetFrontendAnimeList e diff --git a/src/internal/api/client.go b/src/internal/api/client.go index 31e8074..d70f94b 100644 --- a/src/internal/api/client.go +++ b/src/internal/api/client.go @@ -1,6 +1,7 @@ package api import ( + "AutoAnimeDownloader/src/internal/daemon" "AutoAnimeDownloader/src/internal/files" "bytes" "encoding/json" @@ -208,3 +209,171 @@ func (c *Client) StopLoop() error { return c.parseResponse(resp, nil) } + +// LoadStandaloneAnimes returns the tracked standalone media ids, as the file stores them. +func (c *Client) LoadStandaloneAnimes() ([]int, error) { + resp, err := c.doRequest(http.MethodGet, "/api/v1/standalone-animes", nil) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var ids []int + if err := c.parseResponse(resp, &ids); err != nil { + return nil, err + } + + return ids, nil +} + +// SaveStandaloneAnimes replaces the tracked list wholesale — the CLI's `standalones import` +// round-trips an export through it. +func (c *Client) SaveStandaloneAnimes(ids []int) error { + resp, err := c.doRequest(http.MethodPut, "/api/v1/standalone-animes", ids) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + return c.parseResponse(resp, nil) +} + +// AddStandaloneAnime starts tracking one anime and downloads what is available. +func (c *Client) AddStandaloneAnime(mediaID int) (int, error) { + resp, err := c.doRequest(http.MethodPost, "/api/v1/standalone-animes", map[string]int{"media_id": mediaID}) + if err != nil { + return 0, err + } + + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var body struct { + Added int `json:"added"` + } + if err := c.parseResponse(resp, &body); err != nil { + return 0, err + } + + return body.Added, nil +} + +// RemoveStandaloneAnime stops tracking one anime. deleteEpisodes also removes the downloaded +// episode files; without it they stay on disk, marked manually managed. +func (c *Client) RemoveStandaloneAnime(mediaID int, deleteEpisodes bool) error { + resp, err := c.doRequest(http.MethodDelete, + fmt.Sprintf("/api/v1/standalone-animes/%d?delete_episodes=%t", mediaID, deleteEpisodes), nil) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + return c.parseResponse(resp, nil) +} + +// DeleteEpisode removes one episode's torrent, library hardlink and record, and blocks it. +func (c *Client) DeleteEpisode(animeID, episodeNumber int, keepData, block bool) error { + resp, err := c.doRequest(http.MethodDelete, + fmt.Sprintf("/api/v1/animes/%d/episodes/%d?keep_data=%t&block=%t", animeID, episodeNumber, keepData, block), nil) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + return c.parseResponse(resp, nil) +} + +// RedownloadEpisode drops the episode (if already on disk) and queues a fresh download. +func (c *Client) RedownloadEpisode(animeID, episodeNumber int) error { + resp, err := c.doRequest(http.MethodPost, + fmt.Sprintf("/api/v1/animes/%d/episodes/%d/redownload", animeID, episodeNumber), nil) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + return c.parseResponse(resp, nil) +} + +// BatchDeleteEpisodes removes N episodes of one anime in a single request. +func (c *Client) BatchDeleteEpisodes(animeID int, episodes []int, keepData, block bool) (int, error) { + resp, err := c.doRequest(http.MethodPost, + fmt.Sprintf("/api/v1/animes/%d/episodes/batch-delete", animeID), + map[string]any{"episodes": episodes, "keep_data": keepData, "block": block}) + if err != nil { + return 0, err + } + + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var body struct { + Count int `json:"count"` + } + if err := c.parseResponse(resp, &body); err != nil { + return 0, err + } + + return body.Count, nil +} + +// BatchRedownloadEpisodes re-downloads N episodes of one anime in a single request. +func (c *Client) BatchRedownloadEpisodes(animeID int, episodes []int) (int, error) { + resp, err := c.doRequest(http.MethodPost, + fmt.Sprintf("/api/v1/animes/%d/episodes/batch-redownload", animeID), + map[string]any{"episodes": episodes}) + if err != nil { + return 0, err + } + + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var body struct { + Count int `json:"count"` + } + if err := c.parseResponse(resp, &body); err != nil { + return 0, err + } + + return body.Count, nil +} + +// RunAnimeDebug reproduces the daemon's search/match pipeline for one anime over HTTP and +// returns the summary. Nothing is downloaded. +func (c *Client) RunAnimeDebug(animeID int) (*daemon.DebugSummary, error) { + resp, err := c.doRequest(http.MethodPost, + fmt.Sprintf("/api/v1/debug/anime/%d", animeID), nil) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var summary daemon.DebugSummary + if err := c.parseResponse(resp, &summary); err != nil { + return nil, err + } + + return &summary, nil +} diff --git a/src/internal/api/endpoint_anime_debug.go b/src/internal/api/endpoint_anime_debug.go new file mode 100644 index 0000000..c80e929 --- /dev/null +++ b/src/internal/api/endpoint_anime_debug.go @@ -0,0 +1,47 @@ +package api + +import ( + "AutoAnimeDownloader/src/internal/daemon" + "AutoAnimeDownloader/src/internal/logger" + "net/http" + "strconv" +) + +// handleAnimeDebug runs the daemon's search/match pipeline for one anime over HTTP and returns +// the machine-readable summary. It is the remote equivalent of the daemon's --debug-anime flag, +// so the CLI does not have to shell out to the daemon binary: `autoanimedownloader debug ` +// becomes one API call. +// +// Nothing is downloaded and nothing in downloaded_episodes is touched — the trace (AniList +// response, every Nyaa row, which matched) still goes through the logger, so it shows up in +// `logs` like any other daemon event. +func handleAnimeDebug(server *Server) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + JSONError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "Only POST is allowed") + return + } + + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil || id <= 0 { + JSONError(w, http.StatusBadRequest, "INVALID_ID", "Invalid anime ID") + return + } + + configs, err := server.FileManager.LoadConfigs() + if err != nil { + logger.Logger.Error().Err(err).Msg("Failed to load configs for debug run") + JSONInternalError(w, err) + return + } + + summary, err := daemon.RunAnimeDebug(id, configs, server.FileManager) + if err != nil { + logger.Logger.Error().Err(err).Int("anime_id", id).Msg("Debug run failed") + JSONInternalError(w, err) + return + } + + JSONSuccess(w, http.StatusOK, summary) + } +} \ No newline at end of file diff --git a/src/internal/api/endpoint_anime_settings.go b/src/internal/api/endpoint_anime_settings.go index 4cb6e8c..f95ce9b 100644 --- a/src/internal/api/endpoint_anime_settings.go +++ b/src/internal/api/endpoint_anime_settings.go @@ -11,7 +11,9 @@ import ( // Progress e PONTEIRO porque o PUT e parcial: ausente tem de ser distinguivel de zero, ou um // corpo sem o campo zeraria o progresso salvo. type animeSettingsRequest struct { - Progress *int `json:"progress"` + Progress *int `json:"progress"` + WebhookPresetNames []string `json:"webhook_preset_names,omitempty"` + DownloadFolderOverride *string `json:"download_folder_override,omitempty"` } func handleAnimeSettings(server *Server) http.HandlerFunc { @@ -58,6 +60,12 @@ func handleAnimeSettings(server *Server) http.HandlerFunc { if req.Progress != nil { settings.Progress = *req.Progress } + if req.WebhookPresetNames != nil { + settings.WebhookPresetNames = req.WebhookPresetNames + } + if req.DownloadFolderOverride != nil { + settings.DownloadFolderOverride = *req.DownloadFolderOverride + } if err := server.FileManager.SaveAnimeSettings(id, settings); err != nil { logger.Logger.Error().Err(err).Int("anime_id", id).Msg("Failed to save anime settings") diff --git a/src/internal/api/endpoint_batch_actions.go b/src/internal/api/endpoint_batch_actions.go new file mode 100644 index 0000000..9055d7b --- /dev/null +++ b/src/internal/api/endpoint_batch_actions.go @@ -0,0 +1,144 @@ +package api + +import ( + "AutoAnimeDownloader/src/internal/daemon" + "AutoAnimeDownloader/src/internal/files" + "AutoAnimeDownloader/src/internal/logger" + "encoding/json" + "net/http" + "strconv" +) + +// batchEpisodeRequest is the body of the batch delete/redownload endpoints: a list of episode +// numbers for one anime. The web UI's row selection and the CLI both need to act on N episodes +// in one request — N single-episode calls would each fire a remove+re-add and race each other. +type batchEpisodeRequest struct { + Episodes []int `json:"episodes"` +} + +func handleBatchEpisodeDelete(server *Server) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + JSONError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "Only POST is allowed") + return + } + + animeId, err := strconv.Atoi(r.PathValue("id")) + if err != nil || animeId <= 0 { + JSONError(w, http.StatusBadRequest, "INVALID_ID", "Invalid anime ID") + return + } + + var body batchEpisodeRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.Episodes) == 0 { + JSONError(w, http.StatusBadRequest, "INVALID_BODY", "Body must be {\"episodes\": [, ...]}") + return + } + + keys := make([]files.EpisodeKey, 0, len(body.Episodes)) + for _, ep := range body.Episodes { + if ep <= 0 { + JSONError(w, http.StatusBadRequest, "INVALID_EPISODE_NUMBER", "Episode numbers must be positive") + return + } + keys = append(keys, files.EpisodeKey{AnimeID: animeId, Episode: ep}) + } + + if err := daemon.RemoveEpisodesWithLinks(server.FileManager, server.Torrents, server.Librarian, keys); err != nil { + logger.Logger.Error().Err(err).Int("anime_id", animeId).Int("episodes", len(keys)).Msg("Failed to batch delete episodes") + JSONInternalError(w, err) + return + } + + for _, k := range keys { + if err := server.FileManager.BlockEpisode(k); err != nil { + logger.Logger.Warn().Err(err).Int("episode", k.Episode).Msg("Failed to block episode after batch delete") + } + } + + logger.Logger.Info().Int("anime_id", animeId).Int("episodes", len(keys)).Msg("Batch deleted episodes") + JSONSuccess(w, http.StatusOK, map[string]any{"message": "Episodes deleted", "count": len(keys)}) + } +} + +func handleBatchEpisodeRedownload(server *Server) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + JSONError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "Only POST is allowed") + return + } + + animeId, err := strconv.Atoi(r.PathValue("id")) + if err != nil || animeId <= 0 { + JSONError(w, http.StatusBadRequest, "INVALID_ID", "Invalid anime ID") + return + } + + var body batchEpisodeRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.Episodes) == 0 { + JSONError(w, http.StatusBadRequest, "INVALID_BODY", "Body must be {\"episodes\": [, ...]}") + return + } + + configs, err := server.FileManager.LoadConfigs() + if err != nil { + logger.Logger.Error().Err(err).Msg("Failed to load configs") + JSONInternalError(w, err) + return + } + + saved, err := server.FileManager.LoadSavedEpisodes() + if err != nil { + logger.Logger.Error().Err(err).Msg("Failed to load saved episodes") + JSONInternalError(w, err) + return + } + savedSet := make(map[files.EpisodeKey]bool) + for _, ep := range saved { + savedSet[ep.Key()] = true + } + + var keys []files.EpisodeKey + for _, ep := range body.Episodes { + if ep <= 0 { + JSONError(w, http.StatusBadRequest, "INVALID_EPISODE_NUMBER", "Episode numbers must be positive") + return + } + keys = append(keys, files.EpisodeKey{AnimeID: animeId, Episode: ep}) + } + + // Remove first: a surviving record would make the new download untracked (JobOrganize + // joins saved episodes by hash). Abort on failure so we never leave a half-state. + for _, k := range keys { + if savedSet[k] { + if err := daemon.RemoveEpisodesWithLinks(server.FileManager, server.Torrents, server.Librarian, []files.EpisodeKey{k}); err != nil { + logger.Logger.Error().Err(err).Int("anime_id", animeId).Int("episode", k.Episode).Msg("Failed to remove episode before batch redownload") + JSONInternalError(w, err) + return + } + } + if err := server.FileManager.UnblockEpisode(k); err != nil { + logger.Logger.Warn().Err(err).Int("episode", k.Episode).Msg("Failed to unblock episode") + } + } + + downloaded := 0 + for _, k := range keys { + ep, err := daemon.ManualDownloadEpisode(server.FileManager, server.Torrents, k.AnimeID, k.Episode, configs) + if err != nil { + logger.Logger.Error().Err(err).Int("anime_id", animeId).Int("episode", k.Episode).Msg("Failed to redownload episode") + JSONDownloadError(w, err, "REDOWNLOAD_FAILED") + return + } + if err := server.FileManager.UpsertEpisodes([]files.EpisodeStruct{ep}); err != nil { + logger.Logger.Error().Err(err).Msg("Failed to save episode to file") + JSONInternalError(w, err) + return + } + downloaded++ + } + + logger.Logger.Info().Int("anime_id", animeId).Int("episodes", downloaded).Msg("Batch redownloaded episodes") + JSONSuccess(w, http.StatusOK, map[string]any{"message": "Episodes redownload started", "count": downloaded}) + } +} \ No newline at end of file diff --git a/src/internal/api/endpoint_config.go b/src/internal/api/endpoint_config.go index 22b212f..67e905c 100644 --- a/src/internal/api/endpoint_config.go +++ b/src/internal/api/endpoint_config.go @@ -127,6 +127,7 @@ func handleUpdateConfig(server *Server) http.HandlerFunc { // campo nao ter funcionado. if server.Torrents != nil { server.Torrents.SetMaxActiveDownloads(config.MaxConcurrentDownloads) + server.Torrents.SetDisableSeeding(config.DisableSeeding) } JSONSuccess(w, http.StatusOK, map[string]string{"message": "Configuration updated successfully"}) diff --git a/src/internal/api/endpoint_config_test.go b/src/internal/api/endpoint_config_test.go index ff3b751..d1be1da 100644 --- a/src/internal/api/endpoint_config_test.go +++ b/src/internal/api/endpoint_config_test.go @@ -205,6 +205,14 @@ func (m *mockFileManager) RemoveStandaloneAnime(mediaID int) error { return nil } +func (m *mockFileManager) SaveStandaloneAnimes(ids []int) error { + if m.saveErr != nil { + return m.saveErr + } + m.standaloneAnimes = ids + return nil +} + func TestHandleGetConfig(t *testing.T) { state := daemon.NewState() mockFM := &mockFileManager{} diff --git a/src/internal/api/endpoint_notifications.go b/src/internal/api/endpoint_notifications.go index c1acae4..1a20037 100644 --- a/src/internal/api/endpoint_notifications.go +++ b/src/internal/api/endpoint_notifications.go @@ -1,9 +1,12 @@ package api import ( + "AutoAnimeDownloader/src/internal/files" "AutoAnimeDownloader/src/internal/logger" "AutoAnimeDownloader/src/internal/notifications" + "encoding/json" "errors" + "fmt" "net/http" ) @@ -33,3 +36,43 @@ func handleNotificationWebhookTest(server *Server) http.HandlerFunc { JSONSuccess(w, http.StatusOK, map[string]string{"message": "Webhook fired successfully"}) } } + +func handleNotificationWebhookPreview(server *Server) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + JSONError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "Only POST is allowed") + return + } + preview := struct { + Name string `json:"name"` + }{} + if err := json.NewDecoder(r.Body).Decode(&preview); err != nil { + JSONError(w, http.StatusBadRequest, "INVALID_JSON", "Invalid JSON body") + return + } + cfg, err := server.FileManager.LoadConfigs() + if err != nil { + logger.Logger.Error().Err(err).Msg("Failed to load configs for webhook preview") + JSONInternalError(w, err) + return + } + var preset *files.WebhookPreset + for i := range cfg.Notifications.Webhooks { + if cfg.Notifications.Webhooks[i].Name == preview.Name { + preset = &cfg.Notifications.Webhooks[i] + break + } + } + if preset == nil { + JSONError(w, http.StatusNotFound, "WEBHOOK_NOT_FOUND", fmt.Sprintf("webhook preset %q not found", preview.Name)) + return + } + url, method, headers, body := notifications.WebhookPreview(*preset) + JSONSuccess(w, http.StatusOK, map[string]interface{}{ + "url": url, + "method": method, + "headers": headers, + "body": body, + }) + } +} diff --git a/src/internal/api/endpoint_notifications_test.go b/src/internal/api/endpoint_notifications_test.go index 9873fff..567d0f5 100644 --- a/src/internal/api/endpoint_notifications_test.go +++ b/src/internal/api/endpoint_notifications_test.go @@ -2,11 +2,84 @@ package api import ( "AutoAnimeDownloader/src/internal/files" + "bytes" + "encoding/json" "net/http" "net/http/httptest" "testing" ) +// TestHandleWebhookPreview_NotFound returns 404 when the webhook preset does not exist. +func TestHandleWebhookPreview_NotFound(t *testing.T) { + server := &Server{FileManager: &mockFileManager{}} + handler := handleNotificationWebhookPreview(server) + + body, _ := json.Marshal(map[string]string{"name": "missing"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/notifications/webhooks/missing/preview", bytes.NewReader(body)) + req.SetPathValue("name", "missing") + rec := httptest.NewRecorder() + handler(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rec.Code) + } +} + +// TestHandleWebhookPreview_ReturnsVars returns the interpolated URL, method, headers, and body +// without firing the webhook. +func TestHandleWebhookPreview_ReturnsVars(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = r + })) + defer srv.Close() + + fm := &mockFileManager{configs: &files.Config{ + Notifications: files.NotificationsConfig{ + Webhooks: []files.WebhookPreset{ + {Name: "my-hook", URL: "https://example.com/webhook", Method: "POST", Headers: map[string]string{"X-Test": "{{title}}"}, Body: "{{message}}"}, + }, + }, + }} + server := &Server{FileManager: fm} + handler := handleNotificationWebhookPreview(server) + + body, _ := json.Marshal(map[string]string{"name": "my-hook"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/notifications/webhooks/my-hook/preview", bytes.NewReader(body)) + rec := httptest.NewRecorder() + handler(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d — body: %s", rec.Code, rec.Body.String()) + } + + var resp struct { + Success bool `json:"success"` + Data map[string]interface{} `json:"data"` + } + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + t.Logf("response data: %+v", resp.Data) + + url, _ := resp.Data["url"].(string) + method, _ := resp.Data["method"].(string) + headers, _ := resp.Data["headers"].(map[string]interface{}) + bodyVal, _ := resp.Data["body"].(string) + + if url == "" { + t.Fatalf("expected non-empty URL, got %q", url) + } + if method != "POST" { + t.Fatalf("expected POST method, got %s", method) + } + if _, ok := headers["X-Test"]; !ok { + t.Fatal("expected X-Test header in response") + } + if bodyVal == "" { + t.Fatal("expected non-empty body") + } +} + func TestHandleWebhookTest_NotFound(t *testing.T) { server := &Server{FileManager: &mockFileManager{}} handler := handleNotificationWebhookTest(server) diff --git a/src/internal/api/server.go b/src/internal/api/server.go index dd3babd..071008d 100644 --- a/src/internal/api/server.go +++ b/src/internal/api/server.go @@ -33,6 +33,7 @@ type FileManagerInterface interface { LoadAnimeSettings(animeID int) (*files.AnimeSettings, error) SaveAnimeSettings(animeID int, settings files.AnimeSettings) error LoadStandaloneAnimes() ([]int, error) + SaveStandaloneAnimes(ids []int) error AddStandaloneAnime(mediaID int) error RemoveStandaloneAnime(mediaID int) error } @@ -98,11 +99,14 @@ func (s *Server) SetupRoutes() *http.ServeMux { apiMux.HandleFunc("/api/v1/animes/{id}/episodes/{episodeNumber}/release", handleReleaseEpisode(s)) apiMux.HandleFunc("/api/v1/animes/{id}/episodes/{episodeNumber}/replace", handleReplaceEpisodeWithMagnet(s)) apiMux.HandleFunc("/api/v1/animes/{id}/episodes/{episodeNumber}", handleDeleteEpisode(s)) + apiMux.HandleFunc("/api/v1/animes/{id}/episodes/batch-delete", handleBatchEpisodeDelete(s)) + apiMux.HandleFunc("/api/v1/animes/{id}/episodes/batch-redownload", handleBatchEpisodeRedownload(s)) apiMux.HandleFunc("/api/v1/animes/{id}/replace", handleReplaceAnimeWithMagnet(s)) apiMux.HandleFunc("/api/v1/animes/{id}/settings", handleAnimeSettings(s)) apiMux.HandleFunc("/api/v1/anilist/search", handleAniListSearch(s)) apiMux.HandleFunc("/api/v1/standalone-animes", handleStandaloneAnimeAdd(s)) apiMux.HandleFunc("/api/v1/standalone-animes/{id}", handleStandaloneAnimeRemove(s)) + apiMux.HandleFunc("/api/v1/debug/anime/{id}", handleAnimeDebug(s)) apiMux.HandleFunc("/api/v1/check", handleCheck(s)) apiMux.HandleFunc("/api/v1/daemon/start", handleDaemonStart(s)) apiMux.HandleFunc("/api/v1/daemon/stop", handleDaemonStop(s)) @@ -124,6 +128,7 @@ func (s *Server) SetupRoutes() *http.ServeMux { apiMux.HandleFunc("/api/v1/torrents/{hash}/files", handleTorrentFiles(s)) apiMux.HandleFunc("/api/v1/torrents/{hash}/prioritize", handleTorrentPrioritize(s)) apiMux.HandleFunc("/api/v1/notifications/webhooks/{name}/test", handleNotificationWebhookTest(s)) + apiMux.HandleFunc("/api/v1/notifications/webhooks/{name}/preview", handleNotificationWebhookPreview(s)) // WebSocket route (no JSON middleware) mux.HandleFunc("/api/v1/ws", s.handleWebSocket()) diff --git a/src/internal/api/standalone.go b/src/internal/api/standalone.go index 2a6c0ac..e2f0ab2 100644 --- a/src/internal/api/standalone.go +++ b/src/internal/api/standalone.go @@ -1,7 +1,9 @@ package api import ( + "encoding/json" "errors" + "net/http" "AutoAnimeDownloader/src/internal/anilist" "AutoAnimeDownloader/src/internal/logger" @@ -105,3 +107,48 @@ func withStandaloneProgress(fm FileManagerInterface, ml *anilist.MediaList) *ani } return ml } + +// handleStandaloneAnimes serves GET (list), POST (add one) and PUT (replace the whole file). +// +// The file is an int-list with no per-id metadata, so there is nothing to PATCH: the only sane +// bulk write is a wholesale replacement, which is what the CLI's `standalones import` needs to +// round-trip an export. GET and POST stay on the same path as before; PUT is new. +func handleStandaloneAnimes(server *Server) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + ids, err := server.FileManager.LoadStandaloneAnimes() + if err != nil { + logger.Logger.Error().Err(err).Msg("Failed to load standalone animes") + JSONInternalError(w, err) + return + } + JSONSuccess(w, http.StatusOK, ids) + + case http.MethodPost: + handleStandaloneAnimeAdd(server)(w, r) + + case http.MethodPut: + var ids []int + if err := json.NewDecoder(r.Body).Decode(&ids); err != nil { + JSONError(w, http.StatusBadRequest, "INVALID_BODY", "Body must be a JSON array of media ids") + return + } + for _, id := range ids { + if id <= 0 { + JSONError(w, http.StatusBadRequest, "INVALID_BODY", "Media ids must be positive") + return + } + } + if err := server.FileManager.SaveStandaloneAnimes(ids); err != nil { + logger.Logger.Error().Err(err).Msg("Failed to save standalone animes") + JSONInternalError(w, err) + return + } + JSONSuccess(w, http.StatusOK, map[string]any{"message": "Standalone animes replaced", "count": len(ids)}) + + default: + JSONError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "Only GET, POST and PUT are allowed") + } + } +} diff --git a/src/internal/daemon/debug.go b/src/internal/daemon/debug.go index 3646b1c..5b653ff 100644 --- a/src/internal/daemon/debug.go +++ b/src/internal/daemon/debug.go @@ -69,7 +69,7 @@ func RunAnimeDebug(animeId int, configs *files.Config, fileManager FileManagerIn // A MESMA funcao da producao: o debug reportava o pipeline errado quando este trecho era uma // copia mantida em sincronia a mao. savedEpisodes/torrents/keepSet/blocked vazios porque o // debug ja declara que trata todo episodio como nao-baixado. - sel := selectEpisodes(configs, maxEpisodes, anime, episodes, nil, nil, nil, nil, nil) + sel := selectEpisodes(configs, maxEpisodes, anime, episodes, nil, nil, nil, nil, nil, nil) wouldSearch := make(map[int]bool, len(sel.toDownload)) for _, ep := range sel.toDownload { diff --git a/src/internal/daemon/episodes.go b/src/internal/daemon/episodes.go index a19a8cb..a20a6c6 100644 --- a/src/internal/daemon/episodes.go +++ b/src/internal/daemon/episodes.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "math" + "path/filepath" "time" ) @@ -35,9 +36,13 @@ type episodeSelection struct { // selectEpisodes decide, para cada episodio da lista, se ele deve ser baixado ou apagado. // -// E PURA: sem I/O e sem estado entre chamadas (o contador de episodios baixados e local), e por +// E PURA: sem I/O e sem estado entre chamadas (o counters de episodios baixados e local), e por // isso pode rodar duas vezes no mesmo passe — uma com o limite por anime valendo (que e a que // produz as delecoes) e outra com ele levantado quando um pack foi escolhido. Ver decisions.md. +// +// libraryExists, quando nao nil, checa no filesystem se o episodio ja esta na biblioteca +// (usando o caminho salvo em LibraryPaths, ou reverse-parsing do nome do arquivo). Quando nil, +// usa apenas o LibraryPaths salvo (comportamento original, sem I/O). func selectEpisodes( configs *files.Config, maxEpisodes int, @@ -47,6 +52,7 @@ func selectEpisodes( savedEpisodesFullMap map[files.EpisodeKey]files.EpisodeStruct, torrentsHashSet map[string]bool, keepSet, blockedMap map[files.EpisodeKey]bool, + libraryExists func(files.EpisodeKey) bool, ) episodeSelection { var sel episodeSelection downloadedEpisodesOfAnime := 0 @@ -58,6 +64,23 @@ func selectEpisodes( savedEp := savedEpisodesFullMap[key] isInTorrents := episodeInTorrents(savedEp.EpisodeHash, torrentsHashSet) + // Quando a biblioteca ja tem o episodio organizado (LibraryPaths preenchido — ja + // hardlinkado ou baixado direto), nao ha ponto em baixar de novo: o usuario pede + // "no upload" justamente para o daemon nao ficar com nada em disco para semear. + // Checar o LibraryPaths aqui (e nao so o estado do torrent) evita re-baixar apos o + // DisableSeeding remover o torrent. Packs com cobertura parcial ficam pro caminho de + // pack: adiar para o download quando a janela ainda nao foi coberta. + // + // libraryExists checa no filesystem quando o torrent mudou (ex: same episode number + // em outro torrent) e o LibraryPaths pode ter sumido ou ter nome diferente. + episodeInLibrary := len(savedEp.LibraryPaths) > 0 || (libraryExists != nil && libraryExists(key)) + if episodeInLibrary && !savedEp.IsBatch { + // Episodio ja esta na biblioteca: conta como "downloaded" e nao baixa. + downloadedEpisodesOfAnime++ + sel.downloaded = downloadedEpisodesOfAnime + continue + } + shouldDownload, shouldDelete, skipCode := checkEpisode(configs, maxEpisodes, ep, anime, savedEpisodesMap[key], &downloadedEpisodesOfAnime, isInTorrents, keepSet[key], savedEp.IsBatch) if skipCode == IssueMaxEpisodesPerAnime { @@ -124,7 +147,17 @@ func processAnimeEpisodes( // A primeira selecao e a que vale quando nao ha pack, e e ela que produz as delecoes por // limite: com o limite levantado por palpite, handleAlreadySavedEpisode nunca disparava e // keysToDelete vinha vazio para todo mundo. - sel := selectEpisodes(configs, effectiveMax(configs, episodes), anime, episodes, savedEpisodesMap, savedEpisodesFullMap, torrentsHashSet, keepSet, blockedMap) + episodeExists := func(key files.EpisodeKey) bool { + // Check filesystem: look in the completed library directory for any video file + // whose reverse-parsed episode number matches this episode. + // We need the anime name to build the library path. Use the saved episode's name. + if savedEp, ok := savedEpisodesFullMap[key]; ok && savedEp.AnimeName != "" { + animeDir := filepath.Join(configs.CompletedAnimePath, files.SanitizeName(savedEp.AnimeName)) + return files.FindExistingByEpisode(animeDir, key.Episode, files.NewOSFileSystem()) + } + return false + } + sel := selectEpisodes(configs, effectiveMax(configs, episodes), anime, episodes, savedEpisodesMap, savedEpisodesFullMap, torrentsHashSet, keepSet, blockedMap, episodeExists) // Posse por cobertura, ANTES de qualquer busca: o que um pack ja baixado cobre no eixo // absoluto da serie nao volta para o Nyaa (ver coverage.go). @@ -162,7 +195,7 @@ func processAnimeEpisodes( // Segunda selecao, com o limite levantado: os registros de episodes.json precisam // cobrir o pack INTEIRO, senao a contagem mente e a poda apagaria o que o pack // acabou de trazer. - sel = selectEpisodes(configs, len(episodes)+1, anime, episodes, savedEpisodesMap, savedEpisodesFullMap, torrentsHashSet, keepSet, blockedMap) + sel = selectEpisodes(configs, len(episodes)+1, anime, episodes, savedEpisodesMap, savedEpisodesFullMap, torrentsHashSet, keepSet, blockedMap, episodeExists) sel.toDownload = dropAdopted(sel.toDownload, adopted) sel.toDownload, magnetsForEpisodes = assignBatches(animeTitle, axis, sel.toDownload, batches) case packStats.Input == 0: @@ -229,11 +262,11 @@ func processAnimeEpisodes( Str("episode", epName). Msg("No torrent found for episode") result.issues = append(result.issues, searchIssue(anime.Media.Id, animeTitle, ep.Episode, searchStats, configs)) - notifications.Notify(configs, notifications.DownloadFailed, animeTitle, ep.Episode, notifications.ReasonNotFound) + notifications.Notify(configs, notifications.DownloadFailed, animeTitle, ep.Episode, notifications.ReasonNotFound, anime.Media.Id, "") continue } - notifications.Notify(configs, notifications.NewEpisode, animeTitle, ep.Episode, "") + notifications.Notify(configs, notifications.NewEpisode, animeTitle, ep.Episode, "", anime.Media.Id, "") hash := attemptDownloadWithRetries(configs, backend, candidates, epName) @@ -275,7 +308,7 @@ func processAnimeEpisodes( result.issues = append(result.issues, issue) // O batch de notificacoes (BatchWindowSeconds) junta os N episodios do passe numa // mensagem so, entao disco cheio nao vira enxurrada. - notifications.Notify(configs, notifications.DownloadFailed, animeTitle, ep.Episode, reason) + notifications.Notify(configs, notifications.DownloadFailed, animeTitle, ep.Episode, reason, anime.Media.Id, "") } } diff --git a/src/internal/daemon/episodes_test.go b/src/internal/daemon/episodes_test.go index cd5d4dd..a10b91b 100644 --- a/src/internal/daemon/episodes_test.go +++ b/src/internal/daemon/episodes_test.go @@ -1146,7 +1146,7 @@ func TestSelectEpisodes_CountsLimitSkips(t *testing.T) { } configs := &files.Config{MaxEpisodesPerAnime: 3} - sel := selectEpisodes(configs, 3, anime, nodes, map[files.EpisodeKey]bool{}, map[files.EpisodeKey]files.EpisodeStruct{}, map[string]bool{}, nil, nil) + sel := selectEpisodes(configs, 3, anime, nodes, map[files.EpisodeKey]bool{}, map[files.EpisodeKey]files.EpisodeStruct{}, map[string]bool{}, nil, nil, nil) if len(sel.toDownload) != 3 { t.Fatalf("esperava 3 para baixar, obteve %d", len(sel.toDownload)) diff --git a/src/internal/daemon/helpers.go b/src/internal/daemon/helpers.go index 179556c..3474adf 100644 --- a/src/internal/daemon/helpers.go +++ b/src/internal/daemon/helpers.go @@ -96,6 +96,7 @@ func HandleTorrentFailure(hash string, cause error, backend torrents.TorrentBack animeName := "" episodeNumber := 0 + animeID := 0 if saved, err := fileManager.LoadSavedEpisodes(); err != nil { logger.Logger.Warn().Err(err).Str("hash", hash).Msg("Torrent failure: failed to load saved episodes for the webhook") } else { @@ -103,6 +104,7 @@ func HandleTorrentFailure(hash string, cause error, backend torrents.TorrentBack if ep.EpisodeHash == hash { animeName = ep.AnimeName episodeNumber = ep.EpisodeNumber + animeID = ep.AnimeID break } } @@ -116,7 +118,11 @@ func HandleTorrentFailure(hash string, cause error, backend torrents.TorrentBack if configs, err := fileManager.LoadConfigs(); err != nil { logger.Logger.Warn().Err(err).Str("hash", hash).Msg("Torrent failure: failed to load configs, skipping webhook") } else { - notifications.Notify(configs, notifications.DownloadFailed, animeName, episodeNumber, reason) + var animeSettings map[int]files.AnimeSettings + if s, err := fileManager.LoadAnimeSettings(animeID); err == nil && s != nil { + animeSettings = map[int]files.AnimeSettings{animeID: *s} + } + notifications.Notify(configs, notifications.DownloadFailed, animeName, episodeNumber, reason, animeID, hash, animeSettings) } if backend == nil { diff --git a/src/internal/daemon/jobs.go b/src/internal/daemon/jobs.go index d6e10a7..87c565c 100644 --- a/src/internal/daemon/jobs.go +++ b/src/internal/daemon/jobs.go @@ -1,9 +1,11 @@ package daemon import ( + "AutoAnimeDownloader/src/internal/anilist" "AutoAnimeDownloader/src/internal/files" "AutoAnimeDownloader/src/internal/logger" "AutoAnimeDownloader/src/internal/notifications" + "AutoAnimeDownloader/src/internal/nyaa" "AutoAnimeDownloader/src/internal/torrents" "encoding/json" "fmt" @@ -356,15 +358,26 @@ func organizeTorrent(hash string, backend torrents.TorrentBackend, librarian fil } isBatch := matched[0].IsBatch || len(matched) > 1 + // O nome da pasta da biblioteca eh o nome da serie raiz (sem marcadores de season/cour/parte), + // para que todas as temporadas de uma serie caiam na mesma pasta, com a subpasta "Season XX" + // distinguindo cada uma (decisoes.md #45). "The Apothecary Diaries Season 3" vira + // "The Apothecary Diaries", e a season entra so na subpasta. + animeName := nyaa.StripSeasonFromName(matched[0].AnimeName) req := files.OrganizeRequest{ TorrentDataDir: info.DataDir, - AnimeName: matched[0].AnimeName, + AnimeName: animeName, AnimeID: matched[0].AnimeID, CompletedPath: configs.CompletedAnimePath, IsBatch: isBatch, RenameJellyfin: configs.RenameFilesForJellyfin, TotalEpisodes: matched[0].AnimeTotalEpisodes, } + // Extrai o numero da season do nome do anime (ex: "Attack on Titan Season 4" -> 4) + // para organizacao Jellyfin: biblioteca/anime/Season 04/anime - S04E01.ext. + title := anilist.Title{Romaji: &matched[0].AnimeName} + if season, _ := ExtractAnimeSeasonPart(title, nil); season != nil && *season > 0 { + req.Season = season + } if !isBatch { ep := matched[0].EpisodeNumber req.EpisodeNumber = &ep @@ -393,8 +406,19 @@ func organizeTorrent(hash string, backend torrents.TorrentBackend, librarian fil if partiallyOrganized { logger.Logger.Debug().Str("hash", hash).Msg("Organize: group was already partially organized, skipping the completion webhook") } else { - notifications.Notify(configs, notifications.DownloadCompleted, matched[0].AnimeName, matched[0].EpisodeNumber, "") + var animeSettings map[int]files.AnimeSettings + if s, err := fm.LoadAnimeSettings(matched[0].AnimeID); err == nil && s != nil { + animeSettings = map[int]files.AnimeSettings{matched[0].AnimeID: *s} + } + notifications.Notify(configs, notifications.DownloadCompleted, matched[0].AnimeName, matched[0].EpisodeNumber, "", matched[0].AnimeID, matched[0].EpisodeHash, animeSettings) } logger.Logger.Info().Str("hash", hash).Str("anime", matched[0].AnimeName).Int("files", len(created)).Msg("Organized torrent into library") + if configs.DisableSeeding { + if err := backend.Remove(hash, false); err != nil { + logger.Logger.Warn().Err(err).Str("hash", hash).Msg("DisableSeeding: failed to remove torrent and data after organize") + } else { + logger.Logger.Info().Str("hash", hash).Msg("DisableSeeding: removed torrent and download data after organize") + } + } return true } diff --git a/src/internal/daemon/loop.go b/src/internal/daemon/loop.go index baf32ca..5250b36 100644 --- a/src/internal/daemon/loop.go +++ b/src/internal/daemon/loop.go @@ -84,7 +84,6 @@ func StartLoop(p StartLoopPayload) *LoopControl { start := createStartFunc(p) doneCh := start(p.Interval, ctx) - donePtr := &doneCh return &LoopControl{ UpdateInterval: func(newDur time.Duration) { @@ -93,18 +92,13 @@ func StartLoop(p StartLoopPayload) *LoopControl { (*cancelPtr)() ctx, cancel = context.WithCancel(context.Background()) cancelPtr = &cancel - newDone := start(newDur, ctx) - donePtr = &newDone + start(newDur, ctx) }, Cancel: func() { mu.Lock() defer mu.Unlock() (*cancelPtr)() }, - Done: func() <-chan struct{} { - mu.Lock() - defer mu.Unlock() - return *donePtr - }(), + Done: doneCh, } -} +} \ No newline at end of file diff --git a/src/internal/daemon/manual_download.go b/src/internal/daemon/manual_download.go index c10f8d5..fa95982 100644 --- a/src/internal/daemon/manual_download.go +++ b/src/internal/daemon/manual_download.go @@ -114,6 +114,7 @@ func ManualDownloadEpisodeWithMagnet(fm FileManagerInterface, backend torrents.T if _, err := backend.Ensure(configs.DownloadPath()); err != nil { return files.EpisodeStruct{}, err } + backend.SetDisableSeeding(configs.DisableSeeding) details, err := resolveAnimeDetails(fm, animeId, configs.AnilistUsernames) if err != nil { @@ -148,6 +149,7 @@ func ManualDownloadAnimeWithMagnet(fm FileManagerInterface, backend torrents.Tor if _, err := backend.Ensure(configs.DownloadPath()); err != nil { return nil, err } + backend.SetDisableSeeding(configs.DisableSeeding) details, err := resolveAnimeDetails(fm, animeId, configs.AnilistUsernames) if err != nil { @@ -191,6 +193,7 @@ func ManualDownloadEpisode(fm FileManagerInterface, backend torrents.TorrentBack if _, err := backend.Ensure(configs.DownloadPath()); err != nil { return files.EpisodeStruct{}, err } + backend.SetDisableSeeding(configs.DisableSeeding) details, err := resolveAnimeDetails(fm, animeId, configs.AnilistUsernames) if err != nil { diff --git a/src/internal/daemon/orchestration_test.go b/src/internal/daemon/orchestration_test.go index 7918eb3..bfdc136 100644 --- a/src/internal/daemon/orchestration_test.go +++ b/src/internal/daemon/orchestration_test.go @@ -690,8 +690,8 @@ func TestOrganizeTorrent_BatchContinuousNumbering(t *testing.T) { if ok := organizeTorrent(hash, backend, files.NewLibrarian(files.NewOSFileSystem()), fm, fm.configs); !ok { t.Fatal("organizeTorrent should succeed") } - for _, name := range []string{"My Anime Season 2 - E01.mkv", "My Anime Season 2 - E02.mkv"} { - if _, err := os.Stat(filepath.Join(completed, "My Anime Season 2", name)); err != nil { + for _, name := range []string{"My Anime - S02E01.mkv", "My Anime - S02E02.mkv"} { + if _, err := os.Stat(filepath.Join(completed, "My Anime", "Season 2", name)); err != nil { t.Errorf("expected library link %s: %v", name, err) } } diff --git a/src/internal/daemon/selection_test.go b/src/internal/daemon/selection_test.go index 4b49200..8796254 100644 --- a/src/internal/daemon/selection_test.go +++ b/src/internal/daemon/selection_test.go @@ -28,7 +28,7 @@ func TestSelectEpisodes_LimitProducesDeletions(t *testing.T) { savedMap, savedFull := savedNonBatch(anime.Media.Id, 50) episodes := anilist.EpisodeList(anime, 1) - sel := selectEpisodes(limitsConfig(), 12, anime, episodes, savedMap, savedFull, map[string]bool{"h": true}, nil, nil) + sel := selectEpisodes(limitsConfig(), 12, anime, episodes, savedMap, savedFull, map[string]bool{"h": true}, nil, nil, nil) if len(sel.keysToDelete) != 38 { t.Errorf("esperava 38 episódios acima do limite em keysToDelete, obteve %d", len(sel.keysToDelete)) @@ -45,8 +45,8 @@ func TestSelectEpisodes_IsPure(t *testing.T) { savedMap, savedFull := savedNonBatch(anime.Media.Id, 50) episodes := anilist.EpisodeList(anime, 1) - first := selectEpisodes(limitsConfig(), 12, anime, episodes, savedMap, savedFull, map[string]bool{"h": true}, nil, nil) - second := selectEpisodes(limitsConfig(), 12, anime, episodes, savedMap, savedFull, map[string]bool{"h": true}, nil, nil) + first := selectEpisodes(limitsConfig(), 12, anime, episodes, savedMap, savedFull, map[string]bool{"h": true}, nil, nil, nil) + second := selectEpisodes(limitsConfig(), 12, anime, episodes, savedMap, savedFull, map[string]bool{"h": true}, nil, nil, nil) if len(first.toDownload) != len(second.toDownload) || len(first.keysToDelete) != len(second.keysToDelete) { t.Errorf("chamadas iguais devolveram resultados diferentes: %d/%d vs %d/%d", @@ -85,7 +85,7 @@ func TestSelectEpisodes_BatchStateDoesNotChurn(t *testing.T) { episodes := anilist.EpisodeList(anime, 1) for cycle := 1; cycle <= 2; cycle++ { - sel := selectEpisodes(limitsConfig(), 12, anime, episodes, savedMap, savedFull, map[string]bool{"pack": true}, nil, nil) + sel := selectEpisodes(limitsConfig(), 12, anime, episodes, savedMap, savedFull, map[string]bool{"pack": true}, nil, nil, nil) if len(sel.keysToDelete) != 0 { t.Fatalf("ciclo %d: pack salvo não deve gerar deleção, obteve %d", cycle, len(sel.keysToDelete)) } @@ -149,7 +149,7 @@ func TestSelectEpisodes_PackRotation(t *testing.T) { } episodes := anilist.EpisodeList(watchedAll, firstEpisodeToConsider(watchedAll, savedSlice)) keepSet := buildWatchedKeepSet(3, watchedAll.Media.Id, episodes, savedFull, watchedAll.Progress) - sel := selectEpisodes(limitsConfig(), 12, watchedAll, episodes, savedMap, savedFull, map[string]bool{"pack": true}, keepSet, nil) + sel := selectEpisodes(limitsConfig(), 12, watchedAll, episodes, savedMap, savedFull, map[string]bool{"pack": true}, keepSet, nil, nil) deleteSet := make(map[files.EpisodeKey]bool, len(sel.keysToDelete)) for _, k := range sel.keysToDelete { @@ -166,7 +166,7 @@ func TestSelectEpisodes_PackRotation(t *testing.T) { savedMap, savedFull, halfWatched := build(50) episodes = anilist.EpisodeList(halfWatched, firstEpisodeToConsider(halfWatched, nil)) keepSet = buildWatchedKeepSet(3, halfWatched.Media.Id, episodes, savedFull, halfWatched.Progress) - sel = selectEpisodes(limitsConfig(), 12, halfWatched, episodes, savedMap, savedFull, map[string]bool{"pack": true}, keepSet, nil) + sel = selectEpisodes(limitsConfig(), 12, halfWatched, episodes, savedMap, savedFull, map[string]bool{"pack": true}, keepSet, nil, nil) if len(sel.toDownload) != 0 { t.Errorf("com progresso 50 nada novo deve ser buscado, obteve %d", len(sel.toDownload)) } diff --git a/src/internal/daemon/standalone.go b/src/internal/daemon/standalone.go index d881f5a..3e7fa06 100644 --- a/src/internal/daemon/standalone.go +++ b/src/internal/daemon/standalone.go @@ -120,6 +120,7 @@ func DownloadStandaloneAnime(fm FileManagerInterface, backend torrents.TorrentBa if _, err := backend.Ensure(configs.DownloadPath()); err != nil { return 0, err } + backend.SetDisableSeeding(configs.DisableSeeding) anime, err := anilist.GetMediaByID(mediaID, anilist.PriorityCritical) // Cache local serve: o download avulso pedido a mao precisa de titulo e faixa de diff --git a/src/internal/daemon/verification.go b/src/internal/daemon/verification.go index aa30c1e..655cdef 100644 --- a/src/internal/daemon/verification.go +++ b/src/internal/daemon/verification.go @@ -91,6 +91,7 @@ func AnimeVerification(ctx context.Context, fileManager FileManagerInterface, st // seguinte. Roda ANTES do Ensure, quando ainda pode não haver sessão — o passo 0 do // enforce é quem trata isso (ver decisions.md #41). backend.SetMaxActiveDownloads(configs.MaxConcurrentDownloads) + backend.SetDisableSeeding(configs.DisableSeeding) // Ensure the embedded torrent session exists for the current save path (created lazily, // recreated if the save path changed or if the download folder was swapped underneath). diff --git a/src/internal/files/filemanager.go b/src/internal/files/filemanager.go index c6b27b1..a811b42 100644 --- a/src/internal/files/filemanager.go +++ b/src/internal/files/filemanager.go @@ -75,6 +75,17 @@ type WebhookPreset struct { Headers map[string]string `json:"headers"` Body string `json:"body"` Events []string `json:"events"` + // Language is the locale used to render the built-in {{title}}/{{message}} strings when the + // preset body does not supply its own. Empty means "use the daemon default" (pt-BR), so a + // config.json written by an older release keeps firing in Portuguese exactly as before. + // Supported values: "pt-BR", "en". + Language string `json:"language,omitempty"` + // RetryAttempts is the number of retry attempts for transient webhook failures (429 or + // 5xx). 0 disables retry. Default 3. + RetryAttempts int `json:"retry_attempts,omitempty"` + // RetryBackoffSeconds is the base backoff in seconds between retries, doubled each + // attempt (exponential). Default 30. + RetryBackoffSeconds int `json:"retry_backoff_seconds,omitempty"` } type NotificationsConfig struct { @@ -128,6 +139,10 @@ type Config struct { DownloadStatuses []string `json:"download_statuses"` DownloadMediaStatuses []string `json:"download_media_statuses"` DeleteStatuses []string `json:"delete_statuses"` + // DisableSeeding remove o torrent da sessao (e para de semear) logo apos o organize + // concluir, para usuarios que nao querem manter copia em seeding na biblioteca de + // download. Os hardlinks da biblioteca permanecem; so a copia de seeding some. + DisableSeeding bool `json:"disable_seeding,omitempty"` // AnimeIDsAreMediaIDs marca que daemon.MigrateAnimeIDsToMedia ja converteu os AnimeID // gravados de id de ENTRADA (MediaList, por conta) para id de MIDIA (ver decisions.md #43). // O default e false de proposito: um config.json anterior a este campo desserializa por @@ -164,6 +179,13 @@ type AnimeSettings struct { // de packs sucessivos nao tem o que o mova. Ausente le 0, que e o comportamento de antes. // Anime de lista nunca usa este campo: quem manda la e a AniList. Progress int `json:"progress,omitempty"` + // WebhookPresetNames, quando nao vazio, restringe os webhooks disparados para este anime + // apenas aos presets cujos nomes estao nesta lista. Quando vazio/nil, todos os webhooks + // globais disparam normalmente (comportamento atual). + WebhookPresetNames []string `json:"webhook_preset_names,omitempty"` + // DownloadFolderOverride, quando nao vazio, faz os torrents deste anime baixarem para esta + // pasta em vez de DownloadPath. Vazio/nulo usa o caminho global configurado. + DownloadFolderOverride string `json:"download_folder_override,omitempty"` } type FileManager struct { @@ -371,6 +393,23 @@ func (m *FileManager) SaveConfigs(config *Config) error { return m.saveConfigsLocked(config) } +// FindWebhookPreset returns the webhook preset with the given name, or an error if not found. +func (m *FileManager) FindWebhookPreset(name string) (WebhookPreset, error) { + m.mu.Lock() + defer m.mu.Unlock() + + config, err := m.LoadConfigs() + if err != nil { + return WebhookPreset{}, err + } + for _, preset := range config.Notifications.Webhooks { + if preset.Name == name { + return preset, nil + } + } + return WebhookPreset{}, fmt.Errorf("webhook preset %q not found", name) +} + // writeAtomic grava via arquivo temporario + rename, para que um leitor (ou uma queda de // energia no meio da escrita) nunca enxergue o arquivo truncado. Todo estado persistido // aqui e reescrito por inteiro a cada alteracao, entao um WriteFile direto deixa uma diff --git a/src/internal/files/librarian.go b/src/internal/files/librarian.go index 159edeb..fb0d8bb 100644 --- a/src/internal/files/librarian.go +++ b/src/internal/files/librarian.go @@ -51,6 +51,10 @@ type OrganizeRequest struct { // EpisodeNumber is used for the Jellyfin name; required when RenameJellyfin is set // for a single episode. EpisodeNumber *int + // Season, quando definido, cria a pasta "Season XX" dentro da pasta do anime e + // nomeia os arquivos como "Anime - SXXEYY.ext" (padrao Jellyfin). + // Quando nil ou 0, mantem o comportamento atual (pasta do anime, "Anime - EYY.ext"). + Season *int // IsBatch marks multi-episode/movie torrents: the episode number comes from each // file's own name instead of EpisodeNumber. IsBatch bool @@ -80,7 +84,7 @@ func isVideoFile(name string) bool { // sanitizeName strips filesystem-invalid characters. O marcador de season e MANTIDO: uma // pasta por entrada da AniList (decisions.md #45). -func sanitizeName(name string) string { +func SanitizeName(name string) string { sanitized := stripInvalidChars(name) sanitized = strings.TrimSpace(sanitized) sanitized = strings.ReplaceAll(sanitized, " ", " ") @@ -96,6 +100,47 @@ func stripInvalidChars(name string) string { return sanitized } +// FindExistingByEpisode searches the library directory (and season subdirectories) for a video +// file whose reverse-parsed episode number matches targetEp. Returns true if found, false +// otherwise. Used as a fallback when LibraryPaths is empty (e.g., after a daemon restart or +// metadata reset) to prevent duplicate downloads even when torrent filenames change. +func FindExistingByEpisode(destDir string, targetEp int, fs FileSystem) bool { + // Check root anime directory + if found := findEpisodeInDir(destDir, targetEp, fs); found { + return true + } + // Check season subdirectories (e.g., "Season 1", "Season 2") + entries, err := fs.ReadDir(destDir) + if err != nil { + return false + } + for _, e := range entries { + if e.IsDir() && strings.HasPrefix(strings.ToLower(e.Name()), "season ") { + seasonDir := filepath.Join(destDir, e.Name()) + if found := findEpisodeInDir(seasonDir, targetEp, fs); found { + return true + } + } + } + return false +} + +func findEpisodeInDir(dir string, targetEp int, fs FileSystem) bool { + entries, err := fs.ReadDir(dir) + if err != nil { + return false + } + for _, e := range entries { + if e.IsDir() || !isVideoFile(e.Name()) { + continue + } + if ep := nyaa.ExtractEpisodeNumber(e.Name()); ep != nil && *ep == targetEp { + return true + } + } + return false +} + // packEpisodeOffset devolve quanto subtrair do numero lido no nome do arquivo para chegar ao // numero da ENTRADA da AniList. Pack de season >= 2 com numeracao continua (arquivos 13-24 para // uma entrada de 12 episodios) e o caso; qualquer outro devolve 0. @@ -125,7 +170,12 @@ func packEpisodeOffset(numbers []int, totalEpisodes int) int { // jellyfinName returns "Anime - E05.ext". func jellyfinName(animeName string, episodeNumber int, ext string) string { - return fmt.Sprintf("%s - E%02d%s", sanitizeName(animeName), episodeNumber, ext) + return fmt.Sprintf("%s - E%02d%s", SanitizeName(animeName), episodeNumber, ext) +} + +// jellyfinNameSeason returns "Anime - S01E05.ext". +func jellyfinNameSeason(animeName string, season, episodeNumber int, ext string) string { + return fmt.Sprintf("%s - S%02dE%02d%s", SanitizeName(animeName), season, episodeNumber, ext) } func (o *organizer) Organize(req OrganizeRequest) ([]string, error) { @@ -143,7 +193,12 @@ func (o *organizer) Organize(req OrganizeRequest) ([]string, error) { return nil, fmt.Errorf("no video files found in %s", req.TorrentDataDir) } - destDir := filepath.Join(req.CompletedPath, sanitizeName(req.AnimeName)) + destDir := filepath.Join(req.CompletedPath, SanitizeName(req.AnimeName)) + // Com Season definido, cria a subpasta "Season X" (padrao Jellyfin: + // biblioteca/anime/Season 1/anime - S01E01.ext). + if req.Season != nil && *req.Season > 0 { + destDir = filepath.Join(destDir, fmt.Sprintf("Season %d", *req.Season)) + } // Track whether we created destDir, so we can clean it up on a cross-device failure // without leaving an orphan folder in the library. @@ -180,14 +235,23 @@ func (o *organizer) Organize(req OrganizeRequest) ([]string, error) { destName := filepath.Base(rel) switch { case singleJellyfin: - destName = jellyfinName(req.AnimeName, *req.EpisodeNumber, filepath.Ext(rel)) + if req.Season != nil && *req.Season > 0 { + destName = jellyfinNameSeason(req.AnimeName, *req.Season, *req.EpisodeNumber, filepath.Ext(rel)) + } else { + destName = jellyfinName(req.AnimeName, *req.EpisodeNumber, filepath.Ext(rel)) + } case req.IsBatch && req.RenameJellyfin: // Pack: o numero sai do proprio nome do arquivo, para os episodios do pack se // misturarem na pasta com os avulsos em vez de manter o nome cru do fansub. // Sem numero legivel (NCOP/NCED, extra, filme) ou com colisao entre dois // arquivos do mesmo pack, fica o nome cru. if n := nyaa.ExtractEpisodeNumber(destName); n != nil { - if jf := jellyfinName(req.AnimeName, *n-packOffset, filepath.Ext(rel)); !used[jf] { + epNum := *n - packOffset + if req.Season != nil && *req.Season > 0 { + if jf := jellyfinNameSeason(req.AnimeName, *req.Season, epNum, filepath.Ext(rel)); !used[jf] { + destName = jf + } + } else if jf := jellyfinName(req.AnimeName, epNum, filepath.Ext(rel)); !used[jf] { destName = jf } } diff --git a/src/internal/files/librarian_test.go b/src/internal/files/librarian_test.go index 47f4c27..d28c8b1 100644 --- a/src/internal/files/librarian_test.go +++ b/src/internal/files/librarian_test.go @@ -83,7 +83,7 @@ func TestSanitizeNameKeepsSeasonMarker(t *testing.T) { } seen := make(map[string]string) for in, want := range cases { - got := sanitizeName(in) + got := SanitizeName(in) if got != want { t.Errorf("sanitizeName(%q) = %q, want %q", in, got, want) } diff --git a/src/internal/files/standalone.go b/src/internal/files/standalone.go index 04548a2..f66c97e 100644 --- a/src/internal/files/standalone.go +++ b/src/internal/files/standalone.go @@ -53,3 +53,26 @@ func (m *FileManager) RemoveStandaloneAnime(mediaID int) error { return m.saveIntListLocked(m.standaloneAnimesPath, filtered, "standalone animes") } + +// SaveStandaloneAnimes substitui o arquivo inteiro. E o que export/import de avulsos precisa: +// migrar uma instalacao era um JSON int-list, e重建-la pelo CLI sem um writer-by-id era +// impossible — o unico caminho era hand-editar. O caller e responsavel por idempotencia +// (um id duplicado no JSON de entrada e gravado duas vezes, e o LoadStandaloneAnimes devolve +// ambas). +func (m *FileManager) SaveStandaloneAnimes(ids []int) error { + m.mu.Lock() + defer m.mu.Unlock() + + // Dedup preservando ordem: o arquivo e um conjunto, e a ordem de gravacao nao importa, + // mas um export seguido de import nao pode duplicar o que o usuario ja tinha. + seen := make(map[int]bool, len(ids)) + out := make([]int, 0, len(ids)) + for _, id := range ids { + if id <= 0 || seen[id] { + continue + } + seen[id] = true + out = append(out, id) + } + return m.saveIntListLocked(m.standaloneAnimesPath, out, "standalone animes") +} diff --git a/src/internal/frontend/bun.lock b/src/internal/frontend/bun.lock index e7ee142..34d91bc 100644 --- a/src/internal/frontend/bun.lock +++ b/src/internal/frontend/bun.lock @@ -267,7 +267,7 @@ "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.10", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.29", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-YeesxhUmR4h5y5V8KF8iBCqJb8YFrBJ++XBTeGcWIaQOYXGN19JM0FoiOjAhwuDoln8owMgXcWBmgHtYSkky2w=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], @@ -281,7 +281,7 @@ "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], - "caniuse-lite": ["caniuse-lite@1.0.30001781", "", {}, "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw=="], + "caniuse-lite": ["caniuse-lite@1.0.30001816", "", {}, "sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw=="], "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], diff --git a/src/internal/frontend/messages/en.json b/src/internal/frontend/messages/en.json index 2d763bd..192da13 100644 --- a/src/internal/frontend/messages/en.json +++ b/src/internal/frontend/messages/en.json @@ -52,6 +52,7 @@ "status_no_results": "No animes match \"{search}\"", "status_filter_unwatched": "Unwatched", "status_filter_standalone": "Standalone only", + "status_filter_next_airing": "Next airing", "config_title": "Configuration", "config_subtitle": "Configure daemon behavior", "config_missing_banner": "There are missing configurations — please fill them in to continue.", @@ -97,6 +98,8 @@ "config_hint_rename_jellyfin": "Renames episode files to \"Anime Name - E05.mkv\", including the ones inside batch packs, for better metadata matching", "config_label_excluded_list": "Excluded List", "config_hint_excluded_list": "Lists that should not be downloaded", + "config_label_disable_seeding": "Disable all uploading (direct download mode)", + "config_hint_disable_seeding": "Torrents are stopped immediately after download completes, so they never seed. Library hardlinks are kept; only the seeding copies in the download folder are dropped. Enable this if you don't want to share upload bandwidth at any time.", "config_btn_run_check": "Run Check Now", "config_btn_reload": "Reload", "config_btn_save": "Save", @@ -212,6 +215,8 @@ "notifications_loading": "Loading...", "notifications_btn_add": "Add webhook", "notifications_btn_test": "Test", + "notifications_btn_preview": "Preview", + "notifications_preview_title": "Preview", "notifications_btn_remove": "Remove", "notifications_btn_confirm": "Confirm", "notifications_label_name": "Name", @@ -225,6 +230,12 @@ "notifications_toast_saved": "Saved!", "notifications_toast_save_err": "Error saving notifications", "notifications_label_events": "Events", + "notifications_label_language": "Language", + "notifications_hint_language": "Language used to render the built-in {{title}} and {{message}} strings when the body does not supply its own. Empty = keep the daemon default (Portuguese), so a config from an older release keeps firing exactly as before.", + "notifications_label_retry_attempts": "Retry attempts", + "notifications_hint_retry_attempts": "Number of times to retry on transient failures (HTTP 429 or 5xx). 0 disables retry.", + "notifications_label_retry_backoff": "Retry backoff (seconds)", + "notifications_hint_retry_backoff": "Base seconds between retries. Doubled each attempt (exponential backoff).", "notifications_event_new_episode": "New episode detected", "notifications_event_download_failed": "Download failed", "notifications_event_download_completed": "Download completed", @@ -330,6 +341,8 @@ "detail_btn_watched_here": "Watched up to here", "detail_toast_progress_saved": "Progress saved", "detail_toast_progress_error": "Failed to save progress", + "detail_toast_settings_saved": "Anime settings saved", + "detail_toast_settings_error": "Failed to save anime settings", "detail_ep_title": "Episode {number}", "detail_batch_row_title": "Episodes {first}–{last}", "detail_batch_actions_menu": "More actions for the pack", @@ -341,6 +354,13 @@ "detail_select_episode": "Select episode {number}", "detail_anilist_status": "AniList: {status}", "detail_col_info": "Details", + "detail_settings_title": "Per-anime Settings", + "detail_webhook_presets_label": "Webhook Presets (empty = all global webhooks fire)", + "detail_webhook_presets_hint": "Comma-separated preset names. Only these webhooks will fire for this anime.", + "detail_webhook_presets_placeholder": "preset1, preset2", + "detail_download_folder_override_label": "Download Folder Override", + "detail_download_folder_override_hint": "When set, torrents for this anime download to this folder instead of the global download path.", + "detail_download_folder_override_placeholder": "/path/to/custom/downloads", "downloads_filter_all": "All", "downloads_filter_problems": "Problems", "downloads_summary_counts": "{downloading} downloading · {seeding} seeding", @@ -451,5 +471,30 @@ "next_airing": "Next ep. {when}", "status_col_next_airing": "Next ep.", "files_waiting_metadata": "Waiting for the torrent metadata", - "files_toggle": "Show or hide the files of {name}" + "files_toggle": "Show or hide the files of {name}", + "priorities_title": "Torrent Priorities", + "priorities_subtitle": "Controls the preference order used to rank and filter Nyaa releases.", + "priorities_loading": "Loading...", + "priorities_criteria_order": "Criteria Order", + "priorities_fansubs": "Fansubs", + "priorities_resolutions": "Resolutions", + "priorities_sources": "Source", + "priorities_codecs": "Codec", + "priorities_audio": "Audio", + "priorities_ignore_list": "Ignore List", + "priorities_scope_movies_only": "movies only", + "priorities_note_criteria_order": "source and audio only apply to movies — not episodes.", + "priorities_note_codecs": "H.264 plays directly in any player; HEVC/AV1 take less space but trigger transcoding in the browser, which burns the subtitles into the video and desyncs them.", + "priorities_add_item": "Add item", + "priorities_reset_list": "Reset this list", + "priorities_reset_all": "Reset all", + "priorities_save": "Save", + "priorities_saving": "Saving...", + "priorities_saved": "Priorities saved", + "priorities_error_save": "Failed to save priorities", + "priorities_error_load": "Failed to load priorities", + "priorities_move_up": "Move {item} up", + "priorities_move_down": "Move {item} down", + "priorities_remove": "Remove {item}", + "priorities_custom_label": "{item} — added by you, remove with ✕" } diff --git a/src/internal/frontend/messages/pt-BR.json b/src/internal/frontend/messages/pt-BR.json index 09c6945..ec67e54 100644 --- a/src/internal/frontend/messages/pt-BR.json +++ b/src/internal/frontend/messages/pt-BR.json @@ -52,6 +52,7 @@ "status_no_results": "Nenhum anime corresponde a \"{search}\"", "status_filter_unwatched": "Não vistos", "status_filter_standalone": "Só avulsos", + "status_filter_next_airing": "Próximo episódio", "config_title": "Configurações", "config_subtitle": "Configure o comportamento do daemon", "config_missing_banner": "Há configurações faltando — preencha-as para continuar.", @@ -97,6 +98,8 @@ "config_hint_rename_jellyfin": "Renomeia os arquivos de episódio para \"Nome do Anime - E05.mkv\", inclusive os de dentro de packs, para melhor identificação de metadados", "config_label_excluded_list": "Listas excluídas", "config_hint_excluded_list": "Listas que não devem ser baixadas", + "config_label_disable_seeding": "Desativar todo envio (modo download direto)", + "config_hint_disable_seeding": "Os torrents são parados assim que o download termina, então nunca semeiam. Os hardlinks da biblioteca são mantidos; apenas as cópias de seeding na pasta de download são removidas. Ative se você não quiser compartilhar banda de upload em nenhum momento.", "config_btn_run_check": "Verificar Agora", "config_btn_reload": "Recarregar", "config_btn_save": "Salvar", @@ -212,6 +215,8 @@ "notifications_loading": "Carregando...", "notifications_btn_add": "Adicionar webhook", "notifications_btn_test": "Testar", + "notifications_btn_preview": "Preview", + "notifications_preview_title": "Preview", "notifications_btn_remove": "Remover", "notifications_btn_confirm": "Confirmar", "notifications_label_name": "Nome", @@ -225,6 +230,12 @@ "notifications_toast_saved": "Salvo!", "notifications_toast_save_err": "Erro ao salvar notificações", "notifications_label_events": "Eventos", + "notifications_label_language": "Idioma", + "notifications_hint_language": "Idioma usado para montar os {{title}}/{{message}} internos quando o body não traz o próprio. Vazio = mantém o padrão do daemon (português), então um config.json de uma versão antiga continua disparando exatamente como antes.", + "notifications_label_retry_attempts": "Tentativas de retry", + "notifications_hint_retry_attempts": "Número de vezes para tentar novamente em falhas transitórias (HTTP 429 ou 5xx). 0 desativa o retry.", + "notifications_label_retry_backoff": "Backoff de retry (segundos)", + "notifications_hint_retry_backoff": "Segundos base entre tentativas. Dobrado a cada tentativa (backoff exponencial).", "notifications_event_new_episode": "Novo episódio detectado", "notifications_event_download_failed": "Falha no download", "notifications_event_download_completed": "Download concluído", @@ -330,6 +341,8 @@ "detail_btn_watched_here": "Assisti até aqui", "detail_toast_progress_saved": "Progresso salvo", "detail_toast_progress_error": "Falha ao salvar o progresso", + "detail_toast_settings_saved": "Configurações do anime salvas", + "detail_toast_settings_error": "Falha ao salvar configurações do anime", "detail_ep_title": "Episódio {number}", "detail_batch_row_title": "Episódios {first}–{last}", "detail_batch_actions_menu": "Mais ações do pack", @@ -341,6 +354,13 @@ "detail_select_episode": "Selecionar episódio {number}", "detail_anilist_status": "AniList: {status}", "detail_col_info": "Detalhes", + "detail_settings_title": "Configurações por Anime", + "detail_webhook_presets_label": "Presets de Webhook (vazio = todos os webhooks globais disparam)", + "detail_webhook_presets_hint": "Nomes de presets separados por vírgula. Apenas esses webhooks dispararão para este anime.", + "detail_webhook_presets_placeholder": "preset1, preset2", + "detail_download_folder_override_label": "Pasta de Download Personalizada", + "detail_download_folder_override_hint": "Quando definido, os torrents deste anime baixam para esta pasta em vez do caminho global de download.", + "detail_download_folder_override_placeholder": "/caminho/para/downloads/personalizados", "downloads_filter_all": "Todos", "downloads_filter_problems": "Problemas", "downloads_summary_counts": "{downloading} baixando · {seeding} seeding", @@ -451,5 +471,30 @@ "next_airing": "Próx. ep. {when}", "status_col_next_airing": "Próximo ep.", "files_waiting_metadata": "Aguardando a metadata do torrent", - "files_toggle": "Mostrar ou esconder os arquivos de {name}" + "files_toggle": "Mostrar ou esconder os arquivos de {name}", + "priorities_title": "Prioridades dos torrents", + "priorities_subtitle": "Controla a ordem de preferência usada para ranquear e filtrar releases do Nyaa.", + "priorities_loading": "Carregando...", + "priorities_criteria_order": "Ordem dos critérios", + "priorities_fansubs": "Fansubs", + "priorities_resolutions": "Resoluções", + "priorities_sources": "Source", + "priorities_codecs": "Codec", + "priorities_audio": "Áudio", + "priorities_ignore_list": "Lista de bloqueio", + "priorities_scope_movies_only": "só filmes", + "priorities_note_criteria_order": "As entradas source e audio não valem para episódio — só para filmes.", + "priorities_note_codecs": "H.264 toca direto em qualquer player; HEVC/AV1 ocupam menos espaço mas viram transcode no navegador, o que dessincroniza a legenda.", + "priorities_add_item": "Adicionar item", + "priorities_reset_list": "Resetar esta lista", + "priorities_reset_all": "Resetar tudo", + "priorities_save": " salvar", + "priorities_saving": "Salvando...", + "priorities_saved": "Prioridades salvas", + "priorities_error_save": "Falha ao salvar prioridades", + "priorities_error_load": "Falha ao carregar prioridades", + "priorities_move_up": "Mover {item} para cima", + "priorities_move_down": "Mover {item} para baixo", + "priorities_remove": "Remover {item}", + "priorities_custom_label": "{item} — adicionado por você, remova no ✕" } diff --git a/src/internal/frontend/src/lib/api/client.ts b/src/internal/frontend/src/lib/api/client.ts index 1ba6808..79d61b9 100644 --- a/src/internal/frontend/src/lib/api/client.ts +++ b/src/internal/frontend/src/lib/api/client.ts @@ -144,12 +144,22 @@ export interface AnilistHealth { } export interface WebhookPreset { - name: string - url: string - method: string - headers: Record - body: string - events: string[] + name: string + url: string + method: string + headers: Record + body: string + events: string[] + /** + * Locale used to render the built-in {{title}}/{{message}} strings when the preset body does + * not supply its own. Empty means "use the daemon default" (pt-BR), so a config written by + * an older release keeps firing in Portuguese exactly as before. + */ + language?: string + /** Number of retry attempts for transient webhook failures (429 or 5xx). 0 disables retry. */ + retry_attempts?: number + /** Base backoff in seconds between retries, doubled each attempt. */ + retry_backoff_seconds?: number } export interface Priorities { @@ -184,6 +194,8 @@ export interface Config { excluded_list?: string excluded_lists: string[] rename_files_for_jellyfin: boolean + /** Remove o torrent da sessao (para de semear) apos o organize concluir. */ + disable_seeding: boolean download_statuses: string[] download_media_statuses: string[] delete_statuses: string[] @@ -363,6 +375,10 @@ export interface AnimeDetailResponse { export interface AnimeSettings { /** Progresso manual — só é lido para anime avulso (o de lista vem da AniList). */ progress?: number + /** Webhooks permitidos para este anime. Vazio/nulo = todos os globais disparam. */ + webhook_preset_names?: string[] + /** When set, torrents for this anime download to this folder instead of the global DownloadPath. */ + download_folder_override?: string } export async function getAnimeDetail(animeId: number): Promise { @@ -393,6 +409,11 @@ export async function replaceAnimeWithMagnet(animeId: number, magnet: string): P return apiRequest('POST', `/animes/${animeId}/replace`, { magnet }) } +export async function getAnimeSettings(animeId: number): Promise { + const resp = await apiRequest('GET', `/animes/${animeId}/settings`) + return resp +} + export async function updateAnimeSettings(animeId: number, settings: AnimeSettings): Promise { return apiRequest('PUT', `/animes/${animeId}/settings`, settings) } @@ -499,6 +520,26 @@ export async function removeStandaloneAnime(mediaId: number, deleteEpisodes: boo return apiRequest('DELETE', `/standalone-animes/${mediaId}?${params.toString()}`) } +export interface WebhookPreview { + url: string + method: string + headers: Record + body: string +} + +export async function previewWebhook(name: string): Promise { + const res = await fetch('/api/v1/notifications/webhooks/' + encodeURIComponent(name) + '/preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }) + if (!res.ok) { + const data = await res.json() + throw new Error(data.error?.message ?? 'Failed to preview webhook') + } + return (await res.json()).data as WebhookPreview +} + export async function testWebhook(name: string): Promise { const res = await fetch( `/api/v1/notifications/webhooks/${encodeURIComponent(name)}/test`, @@ -510,3 +551,24 @@ export async function testWebhook(name: string): Promise { } } +export async function batchDeleteEpisodes( + animeId: number, + episodes: number[], + keepData: boolean, + block: boolean, +): Promise { + return apiRequest('POST', `/animes/${animeId}/episodes/batch-delete`, { + episodes, + keep_data: keepData, + block, + }) +} + +export async function batchRedownloadEpisodes(animeId: number, episodes: number[]): Promise { + return apiRequest('POST', `/animes/${animeId}/episodes/batch-redownload`, { episodes }) +} + +export async function runAnimeDebug(animeId: number): Promise { + return apiRequest('POST', `/debug/anime/${animeId}`) +} + diff --git a/src/internal/frontend/src/lib/utils/status.ts b/src/internal/frontend/src/lib/utils/status.ts index 05e3784..fe70c73 100644 --- a/src/internal/frontend/src/lib/utils/status.ts +++ b/src/internal/frontend/src/lib/utils/status.ts @@ -14,14 +14,17 @@ export function filterAnimes( search: string, filterUnwatched: boolean, filterStandalone = false, + filterNextAiring = false, ): AnimeInfo[] { const term = normalizeName(search.trim()) + const now = Date.now() / 1000 return animes.filter(a => { // Busca por QUALQUER titulo do anime — o usuario pode conhecer so o romaji, o kanji ou um // sinonimo, enquanto o card mostra o ingles. if (term && ![a.name, ...(a.alt_names ?? [])].some(n => normalizeName(n).includes(term))) return false if (filterUnwatched && a.episodes_watched >= a.episodes_released && a.episodes_released > 0) return false if (filterStandalone && !a.is_standalone) return false + if (filterNextAiring && (!a.next_airing_at || a.next_airing_at <= now)) return false return true }) } diff --git a/src/internal/frontend/src/routes/AnimeDetail.svelte b/src/internal/frontend/src/routes/AnimeDetail.svelte index da32e20..158c984 100644 --- a/src/internal/frontend/src/routes/AnimeDetail.svelte +++ b/src/internal/frontend/src/routes/AnimeDetail.svelte @@ -20,6 +20,7 @@ replaceEpisodeWithMagnet, replaceAnimeWithMagnet, updateAnimeSettings, + getAnimeSettings, removeStandaloneAnime, deleteTorrent, getLastCheck, @@ -97,11 +98,16 @@ // num bloco recolhível logo abaixo do cabeçalho — recolhido por padrão porque é ajuste fino, // não o que se vem fazer nesta tela. - // Progresso manual do avulso. Prefill de `anime.episodes_watched`, que já traz o valor salvo - // (o backend injeta AnimeSettings.progress no MediaList sintético). - let progressInput = 0; - let progressSaving = false; - $: progressInput = anime?.episodes_watched ?? 0; +// Progresso manual do avulso. Prefill de `anime.episodes_watched`, que já traz o valor salvo +// (o backend injeta AnimeSettings.progress no MediaList sintético). +let progressInput = 0; +let progressSaving = false; +$: progressInput = anime?.episodes_watched ?? 0; + +// Configuracoes por anime (só para avulsos): presets de webhook e pasta de download. +let webhookPresetNamesInput = ""; +let downloadFolderInput = ""; +let settingsSaving = false; async function saveProgress(value: number) { progressSaving = true; @@ -116,6 +122,26 @@ } } + async function saveSettings() { + settingsSaving = true; + try { + const presets = webhookPresetNamesInput + .split(",") + .map(s => s.trim()) + .filter(s => s.length > 0); + await updateAnimeSettings(animeId, { + webhook_preset_names: presets, + download_folder_override: downloadFolderInput || undefined, + }); + toast.success(m.detail_toast_settings_saved()); + await loadData(animeId); + } catch (err) { + toast.error(err instanceof Error ? err.message : m.detail_toast_settings_error()); + } finally { + settingsSaving = false; + } + } + // Live torrent progress, joined onto episodes below. Fetch failures degrade silently: // this is accessory data (mirrors the best-effort join the backend already does in // handleTorrents), so the page must never break because of it. @@ -438,13 +464,16 @@ try { loading = true; - const [detailData, animesData] = await Promise.all([ + const [detailData, animesData, settingsData] = await Promise.all([ getAnimeDetail(id), getAnimes(), + getAnimeSettings(id), ]); detail = detailData; anime = animesData.find((a) => a.anime_id === id) ?? null; + webhookPresetNamesInput = (settingsData.webhook_preset_names ?? []).join(", "); + downloadFolderInput = settingsData.download_folder_override ?? ""; } catch (err) { toast.error(err instanceof Error ? err.message : m.detail_toast_load_error()); } finally { @@ -920,6 +949,35 @@

{$locale && m.detail_progress_hint()}

{/if} + +
+

{$locale && m.detail_settings_title()}

+
+ + +

{$locale && m.detail_webhook_presets_hint()}

+
+
+ + +

{$locale && m.detail_download_folder_override_hint()}

+
+ +
{/if} diff --git a/src/internal/frontend/src/routes/Config.svelte b/src/internal/frontend/src/routes/Config.svelte index eb19008..9f72e05 100644 --- a/src/internal/frontend/src/routes/Config.svelte +++ b/src/internal/frontend/src/routes/Config.svelte @@ -7,7 +7,7 @@ // O layout novo é o índice lateral de 196px com UM grupo visível por vez. Os campos de // lista (anilist_usernames, excluded_lists) trocaram o par "input + botão +" pelo // ChipsInput do artboard 1e. - import { onMount } from "svelte"; + import { onDestroy, onMount } from "svelte"; import { ArrowUpRight, Check } from "@lucide/svelte"; import { getConfig, @@ -15,6 +15,8 @@ triggerCheck, type Config, } from "../lib/api/client.js"; + import { WebSocketClient } from "../lib/websocket/client.js"; + import { wsConnectionState } from "../lib/stores/wsState.js"; import Loading from "../components/Loading.svelte"; import Input from "../components/Input.svelte"; import Button from "../components/ui/Button.svelte"; @@ -67,6 +69,8 @@ hintMaxConcurrent: m.config_hint_max_concurrent(), labelRenameJellyfin: m.config_label_rename_jellyfin(), hintRenameJellyfin: m.config_hint_rename_jellyfin(), + labelDisableSeeding: m.config_label_disable_seeding(), + hintDisableSeeding: m.config_hint_disable_seeding(), labelExcludedList: m.config_label_excluded_list(), hintExcludedList: m.config_hint_excluded_list(), labelDownloadStatuses: m.config_label_download_statuses(), @@ -130,6 +134,7 @@ watched_episodes_to_keep: 0, excluded_lists: [], rename_files_for_jellyfin: false, + disable_seeding: false, download_statuses: ["CURRENT", "REPEATING"], download_media_statuses: ["RELEASING", "FINISHED"], delete_statuses: [], @@ -342,9 +347,18 @@ } } + let wsClient: WebSocketClient | null = null; + onMount(() => { checkQueryParams(); loadConfig(); + wsClient = new WebSocketClient(); + wsClient.connect(() => {}, (state) => wsConnectionState.set(state)); + }); + + onDestroy(() => { + wsClient?.disconnect(); + wsClient = null; }); @@ -557,6 +571,16 @@

{T && T.hintRenameJellyfin}

+
+ +

{T && T.hintDisableSeeding}

+
+
(); let loading = true; let saving = false; + let preview: WebhookPreview | null = null; + let previewing = false; + let previewingName: string = ''; let showWebhookForm = false; let editingIndex: number | null = null; let newWebhook: WebhookPreset = { name: '', url: '', method: 'POST', headers: {}, body: '', events: [...ALL_EVENTS] }; + // `language` e separado do objeto porque e opcional: um select com bind:value={newWebhook.language} + // criaria a chave `language: ''` no objeto, e o backend grava "" como idioma em vez de omitir. + // Um config.json antigo (campo ausente) tem que continuar sem o campo, disparando em pt-BR. + let newWebhookLanguage: string = ''; + // Retry fields are also kept separate from the object: empty string means "unset" (uses daemon + // defaults), and the backend omits the field when 0. Bound to number inputs, so stored as + // strings and parsed to numbers when confirming. + let newWebhookRetryAttempts: string = ''; + let newWebhookRetryBackoff: string = ''; // Os headers viram lista de pares ENQUANTO o formulario esta aberto, e so voltam a ser // Record no Confirmar. O objeto nao serve para editar: renomear uma chave em @@ -87,26 +109,45 @@ function resetForm() { newWebhook = { name: '', url: '', method: 'POST', headers: {}, body: '', events: [...ALL_EVENTS] }; + newWebhookLanguage = ''; + newWebhookRetryAttempts = ''; + newWebhookRetryBackoff = ''; headerRows = [{ key: '', value: '' }]; editingIndex = null; showWebhookForm = false; } function applyPreset(key: string) { - newWebhook = { ...WEBHOOK_PRESETS[key] }; - headerRows = toRows(WEBHOOK_PRESETS[key].headers); + const p = WEBHOOK_PRESETS[key]; + newWebhook = { ...p }; + // `language` e opcional no tipo e no proprio preset: copiar so a propriedade que existir. + newWebhookLanguage = p.language ?? ''; + newWebhookRetryAttempts = p.retry_attempts !== undefined ? String(p.retry_attempts) : ''; + newWebhookRetryBackoff = p.retry_backoff_seconds !== undefined ? String(p.retry_backoff_seconds) : ''; + headerRows = toRows(p.headers); } function editWebhook(index: number) { - newWebhook = { ...notifications.webhooks[index], headers: { ...notifications.webhooks[index].headers }, events: [...(notifications.webhooks[index].events ?? [])] }; - headerRows = toRows(notifications.webhooks[index].headers); + const h = notifications.webhooks[index]; + newWebhook = { ...h, headers: { ...h.headers }, events: [...(h.events ?? [])] }; + newWebhookLanguage = h.language ?? ''; + newWebhookRetryAttempts = h.retry_attempts !== undefined ? String(h.retry_attempts) : ''; + newWebhookRetryBackoff = h.retry_backoff_seconds !== undefined ? String(h.retry_backoff_seconds) : ''; + headerRows = toRows(h.headers); editingIndex = index; showWebhookForm = true; } function confirmWebhook() { if (!newWebhook.name || !newWebhook.url) return; - const webhook = { ...newWebhook, headers: fromRows(headerRows) }; + // So grava `language` quando o usuario escolheu um: um preset sem campo continua sem o + // campo, e o daemon cai no padrao (pt-BR) como uma versao antiga. + const webhook: WebhookPreset = { ...newWebhook, headers: fromRows(headerRows) }; + if (newWebhookLanguage) webhook.language = newWebhookLanguage; + const attempts = Number(newWebhookRetryAttempts); + if (newWebhookRetryAttempts !== '' && Number.isFinite(attempts)) webhook.retry_attempts = attempts; + const backoff = Number(newWebhookRetryBackoff); + if (newWebhookRetryBackoff !== '' && Number.isFinite(backoff)) webhook.retry_backoff_seconds = backoff; if (editingIndex !== null) { notifications.webhooks = notifications.webhooks.map((h, i) => i === editingIndex ? webhook : h); } else { @@ -128,6 +169,20 @@ } } + async function previewWebhookHandler(name: string) { + previewing = true; + previewingName = name; + preview = null; + try { + preview = await previewWebhook(name); + } catch (e) { + toast.error(e instanceof Error ? e.message : m.notifications_toast_test_err()); + } finally { + previewing = false; + previewingName = ''; + } + } + function addHeaderRow() { headerRows = [...headerRows, { key: '', value: '' }]; } @@ -155,6 +210,8 @@ fullConfig = await getConfig(); notifications = fullConfig.notifications ?? { webhooks: [], batch_window_seconds: 0 }; savedWebhookNames = new Set(notifications.webhooks.map(h => h.name)); + preview = null; + previewing = false; toast.success(m.notifications_toast_saved()); } catch (e) { toast.error(e instanceof Error ? e.message : m.notifications_toast_save_err()); @@ -204,16 +261,26 @@ {hook.url.length > 50 ? hook.url.slice(0, 50) + '…' : hook.url}
- {#if savedWebhookNames.has(hook.name)} - - {/if} - + {/if} +{#if savedWebhookNames.has(hook.name)} + + {/if} +
{/if} + {#if preview} +
+

{T && T.previewTitle}

+
+
+ Method: + {preview.method} +
+
+ URL: + {preview.url} +
+
+ Headers: +
+ {#each Object.entries(preview.headers) as [key, value]} + + {key}: {value} + + {/each} +
+
+
+ Body: +
{preview.body}
+
+
+
+ {/if} + {#if !showWebhookForm} @@ -185,7 +222,7 @@ toggle(key, row.item, !row.on)} /> @@ -196,7 +233,7 @@ type="button" on:click={() => move(key, i, -1)} disabled={i === 0} - aria-label="Mover {row.item} para cima" + aria-label={T && T.moveUp({ item: row.item })} class="text-subtle hover:text-heading disabled:opacity-30 disabled:cursor-not-allowed transition-colors" > ↑ @@ -205,7 +242,7 @@ type="button" on:click={() => move(key, i, 1)} disabled={i === config.priorities[key].length - 1} - aria-label="Mover {row.item} para baixo" + aria-label={T && T.moveDown({ item: row.item })} class="text-subtle hover:text-heading disabled:opacity-30 disabled:cursor-not-allowed transition-colors" > ↓ @@ -215,7 +252,7 @@