diff --git a/.agents/skills/tmdb-api/SKILL.md b/.agents/skills/tmdb-api/SKILL.md new file mode 100644 index 0000000..f1e1bda --- /dev/null +++ b/.agents/skills/tmdb-api/SKILL.md @@ -0,0 +1,347 @@ +--- +name: tmdb-api +description: Use when working with The Movie Database (TMDB) API v3 — fetching movie, TV series, season, episode, or person data; building search or discovery features; handling authentication, ratings, watchlists, or watch providers; using append_to_response to batch requests; or understanding which endpoints exist and what parameters they accept. +--- + +# TMDB API v3 + +TMDB's REST API is versioned at `/3/`. All endpoints require `?api_key=YOUR_KEY` or an `Authorization: Bearer TOKEN` header. Base URL: `https://api.themoviedb.org`. + +## Authentication + +Three session types exist, each with different write access: + +| Type | Creation | Write access | +|------|----------|--------------| +| User session | Token → login → session flow | Ratings, watchlist, favorites | +| Guest session | `GET /3/authentication/guest_session/new` | Ratings only | +| API key only | None needed | Read-only | + +**User session flow:** +1. `GET /3/authentication/token/new` — get a request token +2. Redirect user to `https://www.themoviedb.org/authenticate/{request_token}` +3. `POST /3/authentication/session/new` with `{ "request_token": "..." }` — exchange for session_id + +Guest sessions expire after a period of inactivity and cannot access account endpoints. + +## Common Parameters + +`language` (BCP-47, e.g. `en-US`) applies to most endpoints and affects translated fields like `title`, `overview`, and `tagline`. `page` is 1-based; most paginated endpoints cap at 500 pages. + +`append_to_response` accepts a comma-separated list of sub-resources (max 20) and applies **only** to detail endpoints: +- `GET /3/movie/{movie_id}` +- `GET /3/tv/{series_id}` +- `GET /3/tv/{series_id}/season/{season_number}` +- `GET /3/tv/{series_id}/season/{season_number}/episode/{episode_number}` +- `GET /3/person/{person_id}` + +Example: `/3/movie/550?append_to_response=credits,videos,images` returns the movie detail with credits, videos, and images merged into one response, saving two round trips. + +## Movies + +### Movie Lists + +| Endpoint | Description | Extra params | +|----------|-------------|--------------| +| `GET /3/movie/popular` | Popularity-ranked | `region` (ISO-3166-1) | +| `GET /3/movie/top_rated` | Highest rated | `region` | +| `GET /3/movie/now_playing` | In theatres | `region` | +| `GET /3/movie/upcoming` | Coming soon | `region` | +| `GET /3/movie/latest` | Most recently added | — | +| `GET /3/movie/changes` | Changed movie IDs | `start_date`, `end_date` | + +### Movie Detail + +`GET /3/movie/{movie_id}` — full metadata including `belongs_to_collection`, `budget`, `revenue`, `runtime`, `release_date`. + +### Movie Sub-resources + +| Endpoint | Returns | +|----------|---------| +| `/3/movie/{id}/credits` | Cast and crew | +| `/3/movie/{id}/images` | Posters, backdrops, logos | +| `/3/movie/{id}/videos` | Trailers, clips (YouTube/Vimeo) | +| `/3/movie/{id}/keywords` | Associated keywords | +| `/3/movie/{id}/recommendations` | Similar movies TMDB recommends | +| `/3/movie/{id}/similar` | Same genre/keywords | +| `/3/movie/{id}/release_dates` | Per-country release dates and certifications | +| `/3/movie/{id}/watch/providers` | Streaming/rent/buy availability by region | +| `/3/movie/{id}/alternative_titles` | Title variations by country | +| `/3/movie/{id}/translations` | Translated metadata | +| `/3/movie/{id}/reviews` | User reviews | +| `/3/movie/{id}/lists` | User lists containing this movie | +| `/3/movie/{id}/account_states` | Auth required — user's rating, watchlist, favorite status | +| `/3/movie/{id}/rating` | POST/DELETE — add or remove a rating | +| `/3/movie/{id}/external_ids` | IMDb, Wikidata, Facebook, Instagram, Twitter | + +All of the above are also valid values for `append_to_response` on the movie detail endpoint. + +### Images + +`GET /3/movie/{id}/images` returns three arrays: `backdrops`, `posters`, `logos`. Each image object has `file_path`, `width`, `height`, `vote_average`, and `iso_639_1` (language tag, or `null` for untagged). + +To get English images as fallback when querying in another language, add `include_image_language=en,null`. Using only `null` skips English-tagged images, which are often the most abundant. + +## TV Series + +### TV Lists + +| Endpoint | Description | Extra params | +|----------|-------------|--------------| +| `GET /3/tv/popular` | Popularity-ranked | — | +| `GET /3/tv/top_rated` | Highest rated | — | +| `GET /3/tv/airing_today` | Airing today | `timezone` | +| `GET /3/tv/on_the_air` | Airing in next 7 days | `timezone` | +| `GET /3/tv/latest` | Most recently added | — | +| `GET /3/tv/changes` | Changed series IDs | `start_date`, `end_date` | + +TV list endpoints do not accept a `region` parameter (unlike movie lists). + +### Series Detail + +`GET /3/tv/{series_id}` — includes `number_of_seasons`, `number_of_episodes`, `networks`, `created_by`, `last_air_date`, `next_episode_to_air`, `seasons` (summary array), `type`, and `status`. + +### TV Sub-resources + +| Endpoint | Returns | +|----------|---------| +| `/3/tv/{id}/credits` | Cast/crew for the **latest season only** | +| `/3/tv/{id}/aggregate_credits` | Cast/crew aggregated across **all seasons** | +| `/3/tv/{id}/images` | Posters, backdrops, logos | +| `/3/tv/{id}/videos` | Trailers, clips | +| `/3/tv/{id}/keywords` | Keywords | +| `/3/tv/{id}/recommendations` | Recommended series | +| `/3/tv/{id}/similar` | Similar series | +| `/3/tv/{id}/content_ratings` | Per-country content ratings (e.g. TV-MA) | +| `/3/tv/{id}/watch/providers` | Streaming availability by region | +| `/3/tv/{id}/alternative_titles` | Title variations | +| `/3/tv/{id}/translations` | Translated metadata | +| `/3/tv/{id}/reviews` | User reviews | +| `/3/tv/{id}/external_ids` | IMDb, TVDB, Wikidata, socials | +| `/3/tv/{id}/account_states` | Auth required — user's rating, watchlist, favorite | +| `/3/tv/{id}/rating` | POST/DELETE — add or remove a rating | +| `/3/tv/{id}/episode_groups` | Grouped episode orderings (e.g. DVD order) | +| `/3/tv/{id}/screened_theatrically` | Episodes that had a theatrical run | + +`credits` vs `aggregate_credits`: use `aggregate_credits` when you need the full series cast list. `credits` only covers the latest season, so recurring characters from earlier seasons may be absent. + +`aggregate_credits` returns a `roles` array per cast member (one entry per character/season) rather than a flat cast list. Sort client-side by `total_episode_count` to approximate series regulars. + +Both `credits` and `aggregate_credits` are valid `append_to_response` values on the series detail endpoint. + +### Seasons + +`GET /3/tv/{series_id}/season/{season_number}` — full season detail including all episodes. + +| Sub-resource | Returns | +|--------------|---------| +| `/season/{n}/credits` | Season-level cast/crew | +| `/season/{n}/aggregate_credits` | Aggregated across the season | +| `/season/{n}/images` | Season posters | +| `/season/{n}/videos` | Season trailers | +| `/season/{n}/translations` | Translated metadata | +| `/season/{n}/external_ids` | TVDB, TMDB IDs | +| `/season/{n}/watch/providers` | Season streaming availability | + +Season 0 is the specials season when it exists. + +### Episodes + +`GET /3/tv/{series_id}/season/{season_number}/episode/{episode_number}` — full episode detail. + +| Sub-resource | Returns | +|--------------|---------| +| `/episode/{n}/credits` | Episode cast/crew (guest stars included) | +| `/episode/{n}/images` | Still frames | +| `/episode/{n}/videos` | Clips | +| `/episode/{n}/translations` | Translated metadata | +| `/episode/{n}/external_ids` | IMDb, TVDB IDs | +| `/episode/{n}/rating` | POST/DELETE — episode rating | + +## People + +`GET /3/person/{person_id}` — biography, birthday, deathday, place of birth, `known_for_department`, `also_known_as`. + +| Endpoint | Returns | +|----------|---------| +| `/3/person/{id}/movie_credits` | Movies as cast or crew | +| `/3/person/{id}/tv_credits` | TV series as cast or crew | +| `/3/person/{id}/combined_credits` | Both, with `media_type` field distinguishing them | +| `/3/person/{id}/images` | Profile photos | +| `/3/person/{id}/tagged_images` | Images tagged with this person | +| `/3/person/{id}/external_ids` | IMDb, TVDB, socials | +| `/3/person/{id}/translations` | Translated biography | +| `/3/person/popular` | Popularity-ranked people list | +| `/3/person/latest` | Most recently added | +| `/3/person/changes` | Changed person IDs | + +All person sub-resources above are valid `append_to_response` values on the person detail endpoint. + +## Search + +| Endpoint | Searches | Extra params | +|----------|----------|--------------| +| `GET /3/search/movie` | Movies | `primary_release_year`, `year`, `region` | +| `GET /3/search/tv` | TV series | `first_air_date_year`, `year` | +| `GET /3/search/person` | People | — | +| `GET /3/search/multi` | Movies, TV, people in one call | — | +| `GET /3/search/collection` | Movie collections | — | +| `GET /3/search/company` | Production companies | — | +| `GET /3/search/keyword` | Keywords | — | + +All search endpoints accept `query` (required), `language`, `page`, and `include_adult`. + +`/3/search/multi` results include a `media_type` field (`"movie"`, `"tv"`, or `"person"`) to distinguish result types. + +## Discover + +Discover is a filtered browsing API — not keyword search. Results are sorted and filterable, not relevance-ranked. + +### Movie Discover + +`GET /3/discover/movie` key parameters: + +| Parameter | Description | +|-----------|-------------| +| `sort_by` | `popularity.desc`, `vote_average.desc`, `revenue.desc`, `release_date.desc`, etc. | +| `with_genres` | Genre IDs — comma = AND, pipe = OR | +| `with_cast` | Person IDs — comma = AND, pipe = OR | +| `with_crew` | Person IDs | +| `with_people` | Cast or crew IDs | +| `with_companies` | Company IDs | +| `with_keywords` | Keyword IDs — comma = AND, pipe = OR | +| `without_genres` | Exclude by genre ID | +| `without_keywords` | Exclude by keyword ID | +| `primary_release_year` | Exact year | +| `primary_release_date.gte` / `.lte` | Date range | +| `release_date.gte` / `.lte` | Any release type date range | +| `vote_average.gte` / `.lte` | Score range | +| `vote_count.gte` / `.lte` | Minimum vote count (use with vote_average filters) | +| `with_runtime.gte` / `.lte` | Runtime in minutes | +| `region` | ISO-3166-1 — affects release date filtering | +| `certification` / `certification_country` | Filter by certification (e.g. R, PG-13) | +| `with_release_type` | Release type: 1=Premiere, 2=Limited, 3=Theatrical, 4=Digital, 5=Physical, 6=TV | +| `with_watch_providers` | Provider IDs — use with `watch_region` | +| `watch_region` | ISO-3166-1 — required for watch provider filtering | +| `with_watch_monetization_types` | `flatrate`, `free`, `ads`, `rent`, `buy` | +| `with_original_language` | ISO-639-1 language code | +| `with_origin_country` | ISO-3166-1 country code | +| `year` | Alias for `primary_release_year` | +| `include_adult` | Default false | +| `include_video` | Include video-only releases, default false | + +### TV Discover + +`GET /3/discover/tv` — same pattern, with TV-specific parameters: + +| Parameter | Description | +|-----------|-------------| +| `sort_by` | `popularity.desc`, `vote_average.desc`, `first_air_date.desc`, etc. | +| `with_genres` | Genre IDs | +| `with_networks` | Network ID (integer, not comma-separated) | +| `with_status` | 0=Returning, 1=Planned, 2=In Production, 3=Ended, 4=Canceled, 5=Pilot | +| `with_type` | 0=Documentary, 1=News, 2=Miniseries, 3=Reality, 4=Scripted, 5=Talk Show, 6=Video | +| `first_air_date_year` | Exact first air year | +| `first_air_date.gte` / `.lte` | Date range | +| `air_date.gte` / `.lte` | Episode air date range | +| `timezone` | Used with air date filters | +| `screened_theatrically` | Boolean — only series with theatrical episodes | +| `include_null_first_air_dates` | Include series without a first air date | + +## Trending + +`GET /3/trending/{media_type}/{time_window}` — `media_type` is `movie`, `tv`, `person`, or `all`; `time_window` is `day` or `week`. + +## Find by External ID + +`GET /3/find/{external_id}?external_source=imdb_id` — look up a TMDB entity by an external identifier. + +Supported `external_source` values: `imdb_id`, `tvdb_id`, `freebase_mid`, `freebase_id`, `tvrage_id`, `wikidata_id`, `facebook_id`, `instagram_id`, `twitter_id`. + +Returns separate arrays: `movie_results`, `tv_results`, `tv_season_results`, `tv_episode_results`, `person_results`. + +## Collections, Networks, Companies, Keywords + +| Endpoint | Description | +|----------|-------------| +| `GET /3/collection/{id}` | Movie collection detail (e.g. Marvel Cinematic Universe) | +| `GET /3/collection/{id}/images` | Collection images | +| `GET /3/collection/{id}/translations` | Translated metadata | +| `GET /3/network/{id}` | Network detail (e.g. HBO) | +| `GET /3/network/{id}/images` | Network logos | +| `GET /3/network/{id}/alternative_names` | Network name aliases | +| `GET /3/company/{id}` | Production company detail | +| `GET /3/company/{id}/images` | Company logos | +| `GET /3/company/{id}/alternative_names` | Company name aliases | +| `GET /3/keyword/{id}` | Keyword detail | +| `GET /3/keyword/{id}/movies` | Movies tagged with this keyword | +| `GET /3/credit/{credit_id}` | Cast/crew credit detail by credit ID | + +## Account & Watchlist (Auth Required) + +All account endpoints require a valid `session_id` query parameter. + +| Endpoint | Description | +|----------|-------------| +| `GET /3/account/{account_id}` | Account details | +| `POST /3/account/{account_id}/favorite` | Add/remove favorite | +| `POST /3/account/{account_id}/watchlist` | Add/remove watchlist entry | +| `GET /3/account/{account_id}/favorite/movies` | Favorite movies | +| `GET /3/account/{account_id}/favorite/tv` | Favorite TV series | +| `GET /3/account/{account_id}/watchlist/movies` | Watchlist movies | +| `GET /3/account/{account_id}/watchlist/tv` | Watchlist TV series | +| `GET /3/account/{account_id}/rated/movies` | Rated movies | +| `GET /3/account/{account_id}/rated/tv` | Rated TV series | +| `GET /3/account/{account_id}/rated/tv/episodes` | Rated episodes | +| `GET /3/account/{account_id}/lists` | User-created lists | + +Guest session ratings use `GET /3/guest_session/{guest_session_id}/rated/movies` (and `/tv`, `/tv/episodes`). + +## Lists (v3) + +| Endpoint | Description | +|----------|-------------| +| `POST /3/list` | Create a list | +| `GET /3/list/{list_id}` | List detail and items | +| `POST /3/list/{list_id}/add_item` | Add a movie | +| `POST /3/list/{list_id}/remove_item` | Remove a movie | +| `POST /3/list/{list_id}/clear` | Remove all items | +| `DELETE /3/list/{list_id}` | Delete the list | +| `GET /3/list/{list_id}/item_status` | Check if a movie is in the list | + +## Configuration & Reference Data + +| Endpoint | Returns | +|----------|---------| +| `GET /3/configuration` | Image base URLs, available sizes, change keys | +| `GET /3/configuration/countries` | Country list with ISO codes | +| `GET /3/configuration/languages` | Language list with ISO codes | +| `GET /3/configuration/jobs` | Department and job name list | +| `GET /3/configuration/primary_translations` | Supported translation locales | +| `GET /3/configuration/timezones` | Timezone list | +| `GET /3/genre/movie/list` | Movie genre IDs and names | +| `GET /3/genre/tv/list` | TV genre IDs and names | +| `GET /3/certification/movie/list` | Movie certifications by country | +| `GET /3/certification/tv/list` | TV certifications by country | +| `GET /3/watch/providers/movie` | Available movie streaming providers | +| `GET /3/watch/providers/tv` | Available TV streaming providers | +| `GET /3/watch/providers/regions` | Regions where watch providers operate | + +## Images + +Image URLs are assembled from the configuration endpoint: `secure_base_url + size + file_path`. + +Common sizes: `w45`, `w92`, `w154`, `w185`, `w300`, `w342`, `w500`, `w780`, `w1280`, `original`. Not all sizes apply to all image types — use the `poster_sizes`, `backdrop_sizes`, `profile_sizes`, etc. arrays from the configuration endpoint to know what's valid. + +## Rate Limits & Errors + +TMDB allows roughly 40 requests per second. Exceeding the limit returns `429 Too Many Requests`. No official rate-limit headers are documented; back off and retry on 429. + +| Status | Meaning | +|--------|---------| +| `401` | Invalid API key or missing authentication | +| `404` | Resource not found | +| `422` | Validation error (check request body) | +| `429` | Rate limit exceeded — back off and retry | + +Error responses include a `status_code` (TMDB's internal code) and `status_message` in the JSON body alongside the HTTP status. diff --git a/apps/backend/drizzle/0008_typical_rocket_racer.sql b/apps/backend/drizzle/0008_typical_rocket_racer.sql new file mode 100644 index 0000000..036deaf --- /dev/null +++ b/apps/backend/drizzle/0008_typical_rocket_racer.sql @@ -0,0 +1,2 @@ +ALTER TABLE `downloads` ADD `import_mode` text;--> statement-breakpoint +ALTER TABLE `downloads` ADD `import_target` text; \ No newline at end of file diff --git a/apps/backend/drizzle/0009_bumpy_ozymandias.sql b/apps/backend/drizzle/0009_bumpy_ozymandias.sql new file mode 100644 index 0000000..e42c843 --- /dev/null +++ b/apps/backend/drizzle/0009_bumpy_ozymandias.sql @@ -0,0 +1,2 @@ +ALTER TABLE `downloads` ADD `source_server_id` text;--> statement-breakpoint +ALTER TABLE `downloads` ADD `manual_import_command_id` integer; \ No newline at end of file diff --git a/apps/backend/drizzle/meta/0008_snapshot.json b/apps/backend/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..8d88aa4 --- /dev/null +++ b/apps/backend/drizzle/meta/0008_snapshot.json @@ -0,0 +1,336 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "364866ab-f386-439e-af39-505eada0b51e", + "prevId": "db1b9834-4df7-4b99-a413-be6d9a0f5428", + "tables": { + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "columns": [ + "key_hash" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "downloads": { + "name": "downloads", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "torrent_filename": { + "name": "torrent_filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "peer_id": { + "name": "peer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "peer_name": { + "name": "peer_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dest_path": { + "name": "dest_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "part_path": { + "name": "part_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "release_size": { + "name": "release_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "release_json": { + "name": "release_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_bytes": { + "name": "expected_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expected_bytes_source": { + "name": "expected_bytes_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expected_bytes_mismatch": { + "name": "expected_bytes_mismatch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "downloaded_bytes": { + "name": "downloaded_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "qb_category": { + "name": "qb_category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "qb_source_server": { + "name": "qb_source_server", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "import_mode": { + "name": "import_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "import_target": { + "name": "import_target", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "downloads_status_idx": { + "name": "downloads_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "downloads_updated_at_idx": { + "name": "downloads_updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "downloads_status_check": { + "name": "downloads_status_check", + "value": "\"downloads\".\"status\" in ('downloading', 'import_queued', 'imported', 'failed')" + }, + "downloads_expected_bytes_source_check": { + "name": "downloads_expected_bytes_source_check", + "value": "\"downloads\".\"expected_bytes_source\" is null or \"downloads\".\"expected_bytes_source\" in ('content_length', 'content_range', 'release_size')" + } + } + }, + "managed_keys": { + "name": "managed_keys", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "managed_keys_key_hash_unique": { + "name": "managed_keys_key_hash_unique", + "columns": [ + "key_hash" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/backend/drizzle/meta/0009_snapshot.json b/apps/backend/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..6b9dec0 --- /dev/null +++ b/apps/backend/drizzle/meta/0009_snapshot.json @@ -0,0 +1,350 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "868c8329-4d5e-4af6-bbca-e9380502b247", + "prevId": "364866ab-f386-439e-af39-505eada0b51e", + "tables": { + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "columns": [ + "key_hash" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "downloads": { + "name": "downloads", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "torrent_filename": { + "name": "torrent_filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "peer_id": { + "name": "peer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "peer_name": { + "name": "peer_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dest_path": { + "name": "dest_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "part_path": { + "name": "part_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "release_size": { + "name": "release_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "release_json": { + "name": "release_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_bytes": { + "name": "expected_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expected_bytes_source": { + "name": "expected_bytes_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expected_bytes_mismatch": { + "name": "expected_bytes_mismatch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "downloaded_bytes": { + "name": "downloaded_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "qb_category": { + "name": "qb_category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "qb_source_server": { + "name": "qb_source_server", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_server_id": { + "name": "source_server_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "import_mode": { + "name": "import_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "import_target": { + "name": "import_target", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "manual_import_command_id": { + "name": "manual_import_command_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "downloads_status_idx": { + "name": "downloads_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "downloads_updated_at_idx": { + "name": "downloads_updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "downloads_status_check": { + "name": "downloads_status_check", + "value": "\"downloads\".\"status\" in ('downloading', 'import_queued', 'imported', 'failed')" + }, + "downloads_expected_bytes_source_check": { + "name": "downloads_expected_bytes_source_check", + "value": "\"downloads\".\"expected_bytes_source\" is null or \"downloads\".\"expected_bytes_source\" in ('content_length', 'content_range', 'release_size')" + } + } + }, + "managed_keys": { + "name": "managed_keys", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "managed_keys_key_hash_unique": { + "name": "managed_keys_key_hash_unique", + "columns": [ + "key_hash" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json index 665f923..040233b 100644 --- a/apps/backend/drizzle/meta/_journal.json +++ b/apps/backend/drizzle/meta/_journal.json @@ -57,6 +57,20 @@ "when": 1782431314780, "tag": "0007_flaky_polaris", "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1782561669632, + "tag": "0008_typical_rocket_racer", + "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1782647842534, + "tag": "0009_bumpy_ozymandias", + "breakpoints": true } ] } diff --git a/apps/backend/src/__tests__/catalog.test.ts b/apps/backend/src/__tests__/catalog.test.ts new file mode 100644 index 0000000..eef0c04 --- /dev/null +++ b/apps/backend/src/__tests__/catalog.test.ts @@ -0,0 +1,672 @@ +import type { Release } from '../lib/release' +import type { DownloadsService, StartQbDownloadResult } from '../modules/downloads/downloads.service' +import { describe, expect, mock, test } from 'bun:test' +import { BadRequestError } from '../lib/errors/BadRequestError' +import { NotFoundError } from '../lib/errors/NotFoundError' +import { CatalogController } from '../modules/catalog/catalog.controller' +import { groupReleasesIntoUnifiedTitles, pickBestPerEpisode, pickBestRelease } from '../modules/catalog/catalog.lib' + +function movie(overrides: Partial = {}): Release { + return { + id: `movie:${Math.random()}`, + title: 'Movie.2024.1080p', + filename: 'Movie.2024.1080p.mkv', + category: 2000, + size: 100, + ...overrides, + } +} + +function episode(overrides: Partial = {}): Release { + return { + id: `episode:${Math.random()}`, + title: 'Show.S01E01.1080p', + filename: 'Show.S01E01.1080p.mkv', + category: 5000, + size: 50, + ...overrides, + } +} + +describe('groupReleasesIntoUnifiedTitles', () => { + test('unifies the same movie across two peers into one title with per-peer buckets', () => { + const titles = groupReleasesIntoUnifiedTitles([ + { peer: { id: 'p1', name: 'Alpha' }, releases: [movie({ tmdbId: 603, size: 100 })] }, + { peer: { id: 'p2', name: 'Beta' }, releases: [movie({ tmdbId: 603, size: 200 })] }, + ]) + + expect(titles).toHaveLength(1) + expect(titles[0]!.tmdbId).toBe(603) + expect(titles[0]!.releaseCount).toBe(2) + expect(titles[0]!.totalSize).toBe(300) + expect(titles[0]!.peers).toHaveLength(2) + + const alpha = titles[0]!.peers.find(p => p.id === 'p1')! + expect(alpha.name).toBe('Alpha') + expect(alpha.releaseCount).toBe(1) + expect(alpha.totalSize).toBe(100) + }) + + test('preserves per-release detail in each peer bucket', () => { + const titles = groupReleasesIntoUnifiedTitles([ + { peer: { id: 'p1', name: 'Alpha' }, releases: [ + episode({ id: 'ep:1', tvdbId: 1396, seriesTitle: 'Breaking Bad', season: 1, episode: 1, size: 50, quality: { resolution: 1080 } }), + ] }, + ]) + + const bucket = titles[0]!.peers[0]! + expect(bucket.releases).toHaveLength(1) + expect(bucket.releases[0]).toMatchObject({ + id: 'ep:1', + filename: 'Show.S01E01.1080p.mkv', + size: 50, + season: 1, + episode: 1, + quality: { resolution: 1080 }, + }) + }) + + test('collapses an id-less release on one peer into the strong-id bucket from another peer', () => { + const titles = groupReleasesIntoUnifiedTitles([ + { peer: { id: 'p1', name: 'Alpha' }, releases: [episode({ seriesTitle: 'Some Show', size: 50 })] }, + { peer: { id: 'p2', name: 'Beta' }, releases: [episode({ seriesTitle: 'Some Show', tvdbId: 999, size: 60 })] }, + ]) + + expect(titles).toHaveLength(1) + expect(titles[0]!.tvdbId).toBe(999) + expect(titles[0]!.key).toContain('id:999') + expect(titles[0]!.peers.map(p => p.id).sort()).toEqual(['p1', 'p2']) + }) + + test('does not alias id-less same-name releases when strong ids are ambiguous', () => { + const titles = groupReleasesIntoUnifiedTitles([ + { peer: { id: 'p1', name: 'Alpha' }, releases: [movie({ title: 'The Thing', tmdbId: 1091 })] }, + { peer: { id: 'p2', name: 'Beta' }, releases: [movie({ title: 'The Thing', tmdbId: 609 })] }, + { peer: { id: 'p3', name: 'Gamma' }, releases: [movie({ title: 'The Thing' })] }, + ]) + + const strongIds = titles + .map(title => title.tmdbId) + .filter((tmdbId): tmdbId is number => tmdbId != null) + .sort((a, b) => a - b) + const idLess = titles.find(title => title.key === 'movie:name:the thing') + + expect(strongIds).toEqual([609, 1091]) + expect(idLess?.releaseCount).toBe(1) + expect(idLess?.tmdbId).toBeUndefined() + }) + + test('sorts unified titles by display title', () => { + const titles = groupReleasesIntoUnifiedTitles([ + { peer: { id: 'p1', name: 'Alpha' }, releases: [movie({ title: 'Zebra', tmdbId: 1 }), movie({ title: 'Apple', tmdbId: 2 })] }, + ]) + + expect(titles.map(t => t.displayTitle)).toEqual(['Apple', 'Zebra']) + }) +}) + +describe('pickBestRelease', () => { + test('returns undefined for an empty list', () => { + expect(pickBestRelease([])).toBeUndefined() + }) + + test('prefers the higher resolution even when a lower one has a larger file', () => { + const sd = movie({ id: 'a', quality: { resolution: 720 }, size: 999 }) + const hd = movie({ id: 'b', quality: { resolution: 1080 }, size: 1 }) + expect(pickBestRelease([sd, hd])).toBe(hd) + }) + + test('breaks a resolution tie by the larger file', () => { + const small = movie({ id: 'a', quality: { resolution: 1080 }, size: 10 }) + const big = movie({ id: 'b', quality: { resolution: 1080 }, size: 20 }) + expect(pickBestRelease([small, big])).toBe(big) + }) + + test('breaks a full tie deterministically by the lowest release id, regardless of input order', () => { + const z = movie({ id: 'zzz', quality: { resolution: 1080 }, size: 10 }) + const a = movie({ id: 'aaa', quality: { resolution: 1080 }, size: 10 }) + expect(pickBestRelease([z, a])).toBe(a) + expect(pickBestRelease([a, z])).toBe(a) + }) +}) + +describe('pickBestPerEpisode', () => { + test('keeps the best release per season/episode key', () => { + const s1e1Sd = episode({ id: 'a', season: 1, episode: 1, quality: { resolution: 720 } }) + const s1e1Hd = episode({ id: 'b', season: 1, episode: 1, quality: { resolution: 1080 } }) + const s1e2 = episode({ id: 'c', season: 1, episode: 2, quality: { resolution: 720 } }) + const best = pickBestPerEpisode([s1e1Sd, s1e1Hd, s1e2]) + expect(best).toHaveLength(2) + expect(best).toContain(s1e1Hd) + expect(best).toContain(s1e2) + expect(best).not.toContain(s1e1Sd) + }) + + test('keeps unnumbered episode releases separate instead of collapsing them to one slot', () => { + const first = episode({ id: 'unparsed:1', tvdbId: 81189, title: 'Show.Special.A', filename: 'Show.Special.A.mkv', quality: { resolution: 720 } }) + const second = episode({ id: 'unparsed:2', tvdbId: 81189, title: 'Show.Special.B', filename: 'Show.Special.B.mkv', quality: { resolution: 1080 } }) + + const best = pickBestPerEpisode([first, second]) + + expect(best).toHaveLength(2) + expect(best).toContain(first) + expect(best).toContain(second) + }) +}) + +describe('catalogController.getCatalog', () => { + function makeConnectors(peers: any[]) { + return { servers: [], peers } + } + + test('aggregates the same title across two initialized peers', async () => { + const p1 = { id: 'p1', name: 'Alpha', isInitialized: true, listReleases: async () => [movie({ tmdbId: 603, size: 100 })] } + const p2 = { id: 'p2', name: 'Beta', isInitialized: true, listReleases: async () => [movie({ tmdbId: 603, size: 200 })] } + const controller = new CatalogController(makeConnectors([p1, p2]) as any) + + const result = await controller.getCatalog() + + expect(result.peers).toEqual([{ id: 'p1', name: 'Alpha' }, { id: 'p2', name: 'Beta' }]) + expect(result.titles).toHaveLength(1) + expect(result.titles[0]!.releaseCount).toBe(2) + expect(result.titles[0]!.peers).toHaveLength(2) + }) + + test('skips a peer whose listReleases rejects but keeps the rest', async () => { + const broken = { + id: 'p1', + name: 'Broken', + isInitialized: true, + listReleases: async () => { + throw new Error('unreachable') + }, + } + const healthy = { id: 'p2', name: 'Healthy', isInitialized: true, listReleases: async () => [movie({ tmdbId: 603 })] } + const controller = new CatalogController(makeConnectors([broken, healthy]) as any) + + const result = await controller.getCatalog() + + expect(result.peers).toEqual([{ id: 'p2', name: 'Healthy' }]) + expect(result.titles).toHaveLength(1) + }) + + test('does not query uninitialized peers', async () => { + const called = mock(async () => [movie({ tmdbId: 603 })]) + const peer = { id: 'p1', name: 'Offline', isInitialized: false, listReleases: called } + const controller = new CatalogController(makeConnectors([peer]) as any) + + const result = await controller.getCatalog() + + expect(called).not.toHaveBeenCalled() + expect(result.peers).toEqual([]) + expect(result.titles).toEqual([]) + }) +}) + +describe('catalogController.getTitleMetadata', () => { + function makeConnectors() { + return { servers: [], peers: [] } + } + + const matrix = { + tmdbId: 603, + title: 'The Matrix', + overview: 'A hacker learns the truth.', + year: 1999, + rating: 8.2, + posterUrl: 'https://image.tmdb.org/t/p/w500/poster.jpg', + backdropUrl: 'https://image.tmdb.org/t/p/w780/backdrop.jpg', + genres: ['Action'], + } + + test('delegates to the tmdb client and returns its metadata', async () => { + const getMetadata = mock(async () => matrix) + const controller = new CatalogController(makeConnectors() as any, { getMetadata } as any) + + const result = await controller.getTitleMetadata('movie', 603) + + expect(result).toMatchObject({ title: 'The Matrix' }) + expect(getMetadata).toHaveBeenCalledWith('movie', 603) + }) + + test('returns null when no tmdb client is configured', async () => { + const controller = new CatalogController(makeConnectors() as any) + + expect(await controller.getTitleMetadata('movie', 603)).toBeNull() + }) + + test('propagates a lookup rejection to the caller', () => { + const getMetadata = mock(async () => { + throw new Error('TMDB exploded') + }) + const controller = new CatalogController(makeConnectors() as any, { getMetadata } as any) + + expect(controller.getTitleMetadata('movie', 603)).rejects.toThrow('TMDB exploded') + }) +}) + +describe('catalogController.getRequestOptions', () => { + function fakeServer(overrides: Partial<{ + id: string + name: string + type: 'radarr' | 'sonarr' + canDestination: boolean + isInitialized: boolean + getRootFolders: () => Promise> + }> = {}) { + return { + id: 'radarr-1', + name: 'Radarr', + type: 'radarr', + canDestination: true, + isInitialized: true, + getRootFolders: async () => [{ path: '/movies', freeSpace: 1000 }], + ...overrides, + } + } + + function makeConnectors(servers: any[]) { + return { servers, peers: [] } + } + + test('returns only initialized destinations and tags movies with mediaType "movie"', async () => { + const radarr = fakeServer({ id: 'radarr-1', name: 'My Radarr', type: 'radarr' }) + const sonarrSourceOnly = fakeServer({ id: 'sonarr-1', name: 'Source Sonarr', type: 'sonarr', canDestination: false }) + const controller = new CatalogController(makeConnectors([radarr, sonarrSourceOnly]) as any) + + const options = await controller.getRequestOptions() + + expect(options).toHaveLength(1) + expect(options[0]!.id).toBe('radarr-1') + expect(options[0]!.name).toBe('My Radarr') + expect(options[0]!.type).toBe('radarr') + expect(options[0]!.mediaType).toBe('movie') + expect(options[0]!).not.toHaveProperty('qualityProfiles') + expect(options[0]!.rootFolders).toEqual([{ path: '/movies', freeSpace: 1000 }]) + }) + + test('tags a destination Sonarr with mediaType "tv"', async () => { + const sonarr = fakeServer({ + id: 'sonarr-1', + name: 'My Sonarr', + type: 'sonarr', + getRootFolders: async () => [{ path: '/tv' }], + }) + const controller = new CatalogController(makeConnectors([sonarr]) as any) + + const options = await controller.getRequestOptions() + + expect(options).toHaveLength(1) + expect(options[0]!.mediaType).toBe('tv') + }) + + test('excludes destinations that are not initialized', async () => { + const radarr = fakeServer({ isInitialized: false }) + const controller = new CatalogController(makeConnectors([radarr]) as any) + + expect(await controller.getRequestOptions()).toEqual([]) + }) + + test('skips a destination whose getRootFolders rejects but keeps others', async () => { + const broken = fakeServer({ + id: 'radarr-broken', + name: 'Broken Radarr', + getRootFolders: async () => { + throw new Error('unreachable') + }, + }) + const healthy = fakeServer({ id: 'radarr-ok', name: 'Healthy Radarr' }) + const controller = new CatalogController(makeConnectors([broken, healthy]) as any) + + const options = await controller.getRequestOptions() + + expect(options).toHaveLength(1) + expect(options[0]!.id).toBe('radarr-ok') + }) +}) + +describe('catalogController.requestDownload', () => { + function fakeServer(overrides: Partial<{ + id: string + name: string + type: 'radarr' | 'sonarr' + canDestination: boolean + add: (params: any) => Promise + }> = {}) { + return { + id: 'radarr-1', + name: 'My Radarr', + type: 'radarr', + canDestination: true, + add: mock(async () => 123), + ...overrides, + } + } + + function fakePeer(overrides: Partial<{ + searchByTmdbId: (tmdbId: string) => Promise + searchByTvdbId: (tvdbId: string) => Promise + }> = {}) { + return { + id: 'peer-1', + name: 'Friend Jack', + searchByTmdbId: overrides.searchByTmdbId ?? mock(async () => [movie({ id: 'rel:1', tmdbId: 603, quality: { resolution: 1080 } })]), + searchByTvdbId: overrides.searchByTvdbId ?? mock(async () => [episode({ id: 'ep:1', tvdbId: 81189, season: 1, episode: 1, quality: { resolution: 1080 } })]), + } + } + + function fakeDownloads(overrides: Partial<{ startDirectDownload: (input: unknown) => Promise }> = {}): DownloadsService { + const testDouble = { + startDirectDownload: overrides.startDirectDownload ?? mock(async () => 'started' as const), + } + return testDouble as unknown as DownloadsService + } + + function makeConnectors(servers: any[], peers: any[] = []) { + return { servers, peers } + } + + test('throws BadRequestError when downloads are not configured', () => { + const radarr = fakeServer() + const controller = new CatalogController(makeConnectors([radarr], [fakePeer()]) as any) + + expect(controller.requestDownload({ + peerId: 'peer-1', + serverId: 'radarr-1', + mediaType: 'movie', + tmdbId: 603, + rootFolderPath: '/movies', + })).rejects.toBeInstanceOf(BadRequestError) + }) + + test('throws NotFoundError for an unknown serverId', () => { + const controller = new CatalogController(makeConnectors([], [fakePeer()]) as any, undefined, fakeDownloads() as any) + + expect(controller.requestDownload({ + peerId: 'peer-1', + serverId: 'missing', + mediaType: 'movie', + tmdbId: 603, + rootFolderPath: '/movies', + })).rejects.toBeInstanceOf(NotFoundError) + }) + + test('throws BadRequestError when the server is not a destination', () => { + const radarr = fakeServer({ canDestination: false }) + const controller = new CatalogController(makeConnectors([radarr], [fakePeer()]) as any, undefined, fakeDownloads() as any) + + expect(controller.requestDownload({ + peerId: 'peer-1', + serverId: 'radarr-1', + mediaType: 'movie', + tmdbId: 603, + rootFolderPath: '/movies', + })).rejects.toBeInstanceOf(BadRequestError) + }) + + test('throws BadRequestError when a movie request targets a Sonarr server', () => { + const sonarr = fakeServer({ id: 'sonarr-1', name: 'My Sonarr', type: 'sonarr' }) + const controller = new CatalogController(makeConnectors([sonarr], [fakePeer()]) as any, undefined, fakeDownloads() as any) + + expect(controller.requestDownload({ + peerId: 'peer-1', + serverId: 'sonarr-1', + mediaType: 'movie', + tmdbId: 603, + rootFolderPath: '/movies', + })).rejects.toBeInstanceOf(BadRequestError) + }) + + test('throws BadRequestError when a tv request has no tvdbId', () => { + const sonarr = fakeServer({ id: 'sonarr-1', name: 'My Sonarr', type: 'sonarr' }) + const controller = new CatalogController(makeConnectors([sonarr], [fakePeer()]) as any, undefined, fakeDownloads() as any) + + expect(controller.requestDownload({ + peerId: 'peer-1', + serverId: 'sonarr-1', + mediaType: 'tv', + rootFolderPath: '/tv', + })).rejects.toBeInstanceOf(BadRequestError) + }) + + test('throws NotFoundError when the peer has no release for the tmdbId, and does not add', async () => { + const radarr = fakeServer() + const peer = fakePeer({ searchByTmdbId: mock(async () => []) }) + const controller = new CatalogController(makeConnectors([radarr], [peer]) as any, undefined, fakeDownloads() as any) + + await expect(controller.requestDownload({ + peerId: 'peer-1', + serverId: 'radarr-1', + mediaType: 'movie', + tmdbId: 603, + rootFolderPath: '/movies', + })).rejects.toBeInstanceOf(NotFoundError) + expect(radarr.add).not.toHaveBeenCalled() + }) + + test('adds the movie without search and starts a direct download for the best release', async () => { + const radarr = fakeServer({ add: mock(async () => 123) }) + const best = movie({ id: 'rel:best', tmdbId: 603, quality: { resolution: 1080 }, size: 100 }) + const worse = movie({ id: 'rel:worse', tmdbId: 603, quality: { resolution: 720 }, size: 999 }) + const peer = fakePeer({ searchByTmdbId: mock(async () => [worse, best]) }) + const downloads = fakeDownloads() + const controller = new CatalogController(makeConnectors([radarr], [peer]) as any, undefined, downloads as any) + + const result = await controller.requestDownload({ + peerId: 'peer-1', + serverId: 'radarr-1', + mediaType: 'movie', + tmdbId: 603, + rootFolderPath: '/movies', + }) + + expect(result).toEqual({ ok: true, server: 'My Radarr', started: 1 }) + expect(radarr.add).toHaveBeenCalledWith({ tmdbId: 603, rootFolderPath: '/movies' }) + expect(downloads.startDirectDownload).toHaveBeenCalledWith({ + peerId: 'peer-1', + itemId: 'rel:best', + destinationServerName: 'My Radarr', + destinationServerId: 'radarr-1', + importTarget: { kind: 'movie', movieId: 123 }, + }) + }) + + test('reports zero starts when a duplicate movie direct download is already active', async () => { + const radarr = fakeServer({ add: mock(async () => 123) }) + const peer = fakePeer({ searchByTmdbId: mock(async () => [movie({ id: 'rel:1', tmdbId: 603, quality: { resolution: 1080 } })]) }) + const downloads = fakeDownloads({ startDirectDownload: mock(async (): Promise => 'duplicate') }) + const controller = new CatalogController(makeConnectors([radarr], [peer]), undefined, downloads) + + const result = await controller.requestDownload({ + peerId: 'peer-1', + serverId: 'radarr-1', + mediaType: 'movie', + tmdbId: 603, + rootFolderPath: '/movies', + }) + + expect(result).toEqual({ ok: true, server: 'My Radarr', started: 0 }) + }) + + test('throws NotFoundError when the peer has no episodes for the tvdbId, and does not add', async () => { + const sonarr = fakeServer({ id: 'sonarr-1', name: 'My Sonarr', type: 'sonarr', add: mock(async () => 55) }) + const peer = fakePeer({ searchByTvdbId: mock(async () => []) }) + const controller = new CatalogController(makeConnectors([sonarr], [peer]) as any, undefined, fakeDownloads() as any) + + await expect(controller.requestDownload({ + peerId: 'peer-1', + serverId: 'sonarr-1', + mediaType: 'tv', + tvdbId: 81189, + rootFolderPath: '/tv', + })).rejects.toBeInstanceOf(NotFoundError) + expect(sonarr.add).not.toHaveBeenCalled() + }) + + test('adds the series without search and starts a direct download for the best release per episode', async () => { + const sonarr = fakeServer({ id: 'sonarr-1', name: 'My Sonarr', type: 'sonarr', add: mock(async () => 55) }) + const ep1a = episode({ id: 'ep:1a', tvdbId: 81189, season: 1, episode: 1, quality: { resolution: 720 }, size: 50 }) + const ep1b = episode({ id: 'ep:1b', tvdbId: 81189, season: 1, episode: 1, quality: { resolution: 1080 }, size: 60 }) + const ep2 = episode({ id: 'ep:2', tvdbId: 81189, season: 1, episode: 2, quality: { resolution: 720 }, size: 40 }) + const peer = fakePeer({ searchByTvdbId: mock(async () => [ep1a, ep1b, ep2]) }) + const downloads = fakeDownloads() + const controller = new CatalogController(makeConnectors([sonarr], [peer]) as any, undefined, downloads as any) + + const result = await controller.requestDownload({ + peerId: 'peer-1', + serverId: 'sonarr-1', + mediaType: 'tv', + tvdbId: 81189, + rootFolderPath: '/tv', + }) + + expect(result).toEqual({ ok: true, server: 'My Sonarr', started: 2 }) + expect(sonarr.add).toHaveBeenCalledTimes(1) + expect(sonarr.add).toHaveBeenCalledWith({ tvdbId: 81189, rootFolderPath: '/tv' }) + expect(downloads.startDirectDownload).toHaveBeenCalledTimes(2) + expect(downloads.startDirectDownload).toHaveBeenCalledWith({ + peerId: 'peer-1', + itemId: 'ep:1b', + destinationServerName: 'My Sonarr', + destinationServerId: 'sonarr-1', + importTarget: { kind: 'series', seriesId: 55 }, + }) + expect(downloads.startDirectDownload).toHaveBeenCalledWith({ + peerId: 'peer-1', + itemId: 'ep:2', + destinationServerName: 'My Sonarr', + destinationServerId: 'sonarr-1', + importTarget: { kind: 'series', seriesId: 55 }, + }) + }) + + test('throws BadRequestError when the movie direct download fails to start', async () => { + const radarr = fakeServer({ add: mock(async () => 123) }) + const peer = fakePeer({ searchByTmdbId: mock(async () => [movie({ id: 'rel:1', tmdbId: 603, quality: { resolution: 1080 } })]) }) + const downloads = fakeDownloads({ startDirectDownload: mock(async (): Promise => 'failed') }) + const controller = new CatalogController(makeConnectors([radarr], [peer]) as any, undefined, downloads as any) + + await expect(controller.requestDownload({ + peerId: 'peer-1', + serverId: 'radarr-1', + mediaType: 'movie', + tmdbId: 603, + rootFolderPath: '/movies', + })).rejects.toBeInstanceOf(BadRequestError) + }) + + test('throws BadRequestError when every episode direct download fails to start', async () => { + const sonarr = fakeServer({ id: 'sonarr-1', name: 'My Sonarr', type: 'sonarr', add: mock(async () => 55) }) + const ep1 = episode({ id: 'ep:1', tvdbId: 81189, season: 1, episode: 1, quality: { resolution: 1080 }, size: 60 }) + const ep2 = episode({ id: 'ep:2', tvdbId: 81189, season: 1, episode: 2, quality: { resolution: 720 }, size: 40 }) + const peer = fakePeer({ searchByTvdbId: mock(async () => [ep1, ep2]) }) + const downloads = fakeDownloads({ startDirectDownload: mock(async (): Promise => 'failed') }) + const controller = new CatalogController(makeConnectors([sonarr], [peer]) as any, undefined, downloads as any) + + await expect(controller.requestDownload({ + peerId: 'peer-1', + serverId: 'sonarr-1', + mediaType: 'tv', + tvdbId: 81189, + rootFolderPath: '/tv', + })).rejects.toBeInstanceOf(BadRequestError) + }) + + test('counts only non-failed episode starts when some fail', async () => { + const sonarr = fakeServer({ id: 'sonarr-1', name: 'My Sonarr', type: 'sonarr', add: mock(async () => 55) }) + const ep1 = episode({ id: 'ep:1', tvdbId: 81189, season: 1, episode: 1, quality: { resolution: 1080 }, size: 60 }) + const ep2 = episode({ id: 'ep:2', tvdbId: 81189, season: 1, episode: 2, quality: { resolution: 720 }, size: 40 }) + const peer = fakePeer({ searchByTvdbId: mock(async () => [ep1, ep2]) }) + let calls = 0 + const downloads = fakeDownloads({ startDirectDownload: mock(async () => (calls++ === 0 ? 'started' : 'failed')) }) + const controller = new CatalogController(makeConnectors([sonarr], [peer]) as any, undefined, downloads as any) + + const result = await controller.requestDownload({ + peerId: 'peer-1', + serverId: 'sonarr-1', + mediaType: 'tv', + tvdbId: 81189, + rootFolderPath: '/tv', + }) + + expect(result).toEqual({ ok: true, server: 'My Sonarr', started: 1 }) + }) + + test('counts only newly started episode downloads when duplicates and failures are mixed', async () => { + const sonarr = fakeServer({ id: 'sonarr-1', name: 'My Sonarr', type: 'sonarr', add: mock(async () => 55) }) + const ep1 = episode({ id: 'ep:1', tvdbId: 81189, season: 1, episode: 1, quality: { resolution: 1080 }, size: 60 }) + const ep2 = episode({ id: 'ep:2', tvdbId: 81189, season: 1, episode: 2, quality: { resolution: 720 }, size: 40 }) + const ep3 = episode({ id: 'ep:3', tvdbId: 81189, season: 1, episode: 3, quality: { resolution: 720 }, size: 30 }) + const peer = fakePeer({ searchByTvdbId: mock(async () => [ep1, ep2, ep3]) }) + const outcomes = ['started', 'duplicate', 'failed'] as const + let call = 0 + const downloads = fakeDownloads({ startDirectDownload: mock(async () => outcomes[call++] ?? 'failed') }) + const controller = new CatalogController(makeConnectors([sonarr], [peer]), undefined, downloads) + + const result = await controller.requestDownload({ + peerId: 'peer-1', + serverId: 'sonarr-1', + mediaType: 'tv', + tvdbId: 81189, + rootFolderPath: '/tv', + }) + + expect(result).toEqual({ ok: true, server: 'My Sonarr', started: 1 }) + }) + + test('reports zero starts when every episode direct download is duplicate', async () => { + const sonarr = fakeServer({ id: 'sonarr-1', name: 'My Sonarr', type: 'sonarr', add: mock(async () => 55) }) + const ep1 = episode({ id: 'ep:1', tvdbId: 81189, season: 1, episode: 1, quality: { resolution: 1080 }, size: 60 }) + const ep2 = episode({ id: 'ep:2', tvdbId: 81189, season: 1, episode: 2, quality: { resolution: 720 }, size: 40 }) + const peer = fakePeer({ searchByTvdbId: mock(async () => [ep1, ep2]) }) + const downloads = fakeDownloads({ startDirectDownload: mock(async (): Promise => 'duplicate') }) + const controller = new CatalogController(makeConnectors([sonarr], [peer]), undefined, downloads) + + const result = await controller.requestDownload({ + peerId: 'peer-1', + serverId: 'sonarr-1', + mediaType: 'tv', + tvdbId: 81189, + rootFolderPath: '/tv', + }) + + expect(result).toEqual({ ok: true, server: 'My Sonarr', started: 0 }) + }) +}) + +describe('catalogController.getTmdbStatus', () => { + function makeConnectors() { + return { servers: [], peers: [] } + } + + test('reports not configured when no tmdb client is supplied', async () => { + const controller = new CatalogController(makeConnectors() as any) + + expect(await controller.getTmdbStatus()).toEqual({ configured: false, ok: false }) + }) + + test('reports ok when the client ping resolves true', async () => { + const tmdb = { ping: async () => true } + const controller = new CatalogController(makeConnectors() as any, tmdb as any) + + expect(await controller.getTmdbStatus()).toEqual({ configured: true, ok: true }) + }) + + test('reports configured but not ok when the client ping resolves false', async () => { + const tmdb = { ping: async () => false } + const controller = new CatalogController(makeConnectors() as any, tmdb as any) + + expect(await controller.getTmdbStatus()).toEqual({ configured: true, ok: false }) + }) + + test('reports the error message when the client ping throws', async () => { + const tmdb = { + ping: async () => { + throw new Error('boom') + }, + } + const controller = new CatalogController(makeConnectors() as any, tmdb as any) + + expect(await controller.getTmdbStatus()).toEqual({ configured: true, ok: false, error: 'boom' }) + }) +}) diff --git a/apps/backend/src/__tests__/database.test.ts b/apps/backend/src/__tests__/database.test.ts index 1ae17c3..1366cb5 100644 --- a/apps/backend/src/__tests__/database.test.ts +++ b/apps/backend/src/__tests__/database.test.ts @@ -203,7 +203,7 @@ describe('DownloadsRepository', () => { handle.close() }) - test('persists qbCategory and qbSourceServer round-trip; defaults to null', async () => { + test('persists qB source and manual import command fields round-trip; defaults to null', async () => { const handle = await openDatabase({ appConfigPath: join(tempDir, 'config.jsonc') }) const repository = new DownloadsRepository(handle.db) @@ -219,12 +219,18 @@ describe('DownloadsRepository', () => { release, qbCategory: 'jack-abc12345', qbSourceServer: 'My Radarr', + sourceServerId: 'radarr-1', + manualImportCommandId: 77, }) expect(withQb.qbCategory).toBe('jack-abc12345') expect(withQb.qbSourceServer).toBe('My Radarr') + expect(withQb.sourceServerId).toBe('radarr-1') + expect(withQb.manualImportCommandId).toBe(77) expect(repository.get(withQb.id)?.qbCategory).toBe('jack-abc12345') expect(repository.get(withQb.id)?.qbSourceServer).toBe('My Radarr') + expect(repository.get(withQb.id)?.sourceServerId).toBe('radarr-1') + expect(repository.get(withQb.id)?.manualImportCommandId).toBe(77) const withoutQb = repository.create({ torrentFilename: 'second.torrent', @@ -240,6 +246,8 @@ describe('DownloadsRepository', () => { expect(withoutQb.qbCategory).toBeNull() expect(withoutQb.qbSourceServer).toBeNull() + expect(withoutQb.sourceServerId).toBeNull() + expect(withoutQb.manualImportCommandId).toBeNull() handle.close() }) diff --git a/apps/backend/src/__tests__/downloads-service.test.ts b/apps/backend/src/__tests__/downloads-service.test.ts index 3e54ebe..96e5fb6 100644 --- a/apps/backend/src/__tests__/downloads-service.test.ts +++ b/apps/backend/src/__tests__/downloads-service.test.ts @@ -77,7 +77,7 @@ describe('DownloadsService download progress persistence', () => { }) const service = new DownloadsService(downloadsConfig(), { peers: [peer as any] }, repository) - await service.startQbDownload({ peerId: 'peer-1', itemId: 'movie:1', qbCategory: 'jack-x', qbSourceServer: 'My Radarr' }) + await service.startQbDownload({ peerId: 'peer-1', itemId: 'movie:1', qbCategory: 'jack-x', qbSourceServer: 'My Radarr', sourceServerId: 'radarr-1' }) await waitForStatus(repository, 'import_queued') expect(calls).toHaveLength(1) @@ -101,7 +101,7 @@ describe('DownloadsService download progress persistence', () => { } }) const service = new DownloadsService(downloadsConfig(), { peers: [peer as any] }, repository) - const result = await service.startQbDownload({ peerId: 'peer-1', itemId: 'movie:1', qbCategory: 'jack-x', qbSourceServer: 'My Radarr' }) + const result = await service.startQbDownload({ peerId: 'peer-1', itemId: 'movie:1', qbCategory: 'jack-x', qbSourceServer: 'My Radarr', sourceServerId: 'radarr-1' }) expect(result).toBe('failed') expect(repository.list()).toHaveLength(0) @@ -118,7 +118,7 @@ describe('DownloadsService download progress persistence', () => { } }) const service = new DownloadsService(downloadsConfig(), { peers: [peer as any] }, repository) - await service.startQbDownload({ peerId: 'peer-1', itemId: 'movie:1', qbCategory: 'jack-x', qbSourceServer: 'My Radarr' }) + await service.startQbDownload({ peerId: 'peer-1', itemId: 'movie:1', qbCategory: 'jack-x', qbSourceServer: 'My Radarr', sourceServerId: 'radarr-1' }) await waitForStatus(repository, 'failed') expect(calls).toBe(1) // 404 is permanent — no retry @@ -141,7 +141,7 @@ describe('DownloadsService download progress persistence', () => { }) const service = new DownloadsService(downloadsConfig(), { peers: [peer as any] }, repository) - await service.startQbDownload({ peerId: 'peer-1', itemId: 'movie:1', qbCategory: 'jack-x', qbSourceServer: 'My Radarr' }) + await service.startQbDownload({ peerId: 'peer-1', itemId: 'movie:1', qbCategory: 'jack-x', qbSourceServer: 'My Radarr', sourceServerId: 'radarr-1' }) await waitForStatus(repository, 'import_queued') expect(calls).toBe(2) @@ -167,7 +167,7 @@ describe('DownloadsService download progress persistence', () => { }) const service = new DownloadsService(downloadsConfig(), { peers: [peer as any] }, repository) - await service.startQbDownload({ peerId: 'peer-1', itemId: 'movie:1', qbCategory: 'jack-x', qbSourceServer: 'My Radarr' }) + await service.startQbDownload({ peerId: 'peer-1', itemId: 'movie:1', qbCategory: 'jack-x', qbSourceServer: 'My Radarr', sourceServerId: 'radarr-1' }) await waitForStatus(repository, 'import_queued') expect(resetSpy).toHaveBeenCalledTimes(1) @@ -197,8 +197,8 @@ describe('DownloadsService download progress persistence', () => { } const service = new DownloadsService(downloadsConfig({ maxConcurrentDownloads: 1 }), { peers: [peer as any] }, repository) - await service.startQbDownload({ peerId: 'peer-1', itemId: 'movie:1', qbCategory: 'jack-x', qbSourceServer: 'My Radarr' }) - await service.startQbDownload({ peerId: 'peer-1', itemId: 'movie:2', qbCategory: 'jack-x', qbSourceServer: 'My Radarr' }) + await service.startQbDownload({ peerId: 'peer-1', itemId: 'movie:1', qbCategory: 'jack-x', qbSourceServer: 'My Radarr', sourceServerId: 'radarr-1' }) + await service.startQbDownload({ peerId: 'peer-1', itemId: 'movie:2', qbCategory: 'jack-x', qbSourceServer: 'My Radarr', sourceServerId: 'radarr-1' }) for (let i = 0; i < 100 && repository.list().filter(d => d.status === 'import_queued').length < 2; i++) await Bun.sleep(10) @@ -288,6 +288,7 @@ describe('DownloadsService download progress persistence', () => { itemId: 'movie:1', qbCategory: 'jack-x', qbSourceServer: 'My Radarr', + sourceServerId: 'radarr-1', }) expect(result).toBe('started') @@ -298,6 +299,7 @@ describe('DownloadsService download progress persistence', () => { expect(rows[0]?.status).toBe('import_queued') expect(rows[0]?.qbCategory).toBe('jack-x') expect(rows[0]?.qbSourceServer).toBe('My Radarr') + expect(rows[0]?.sourceServerId).toBe('radarr-1') handle.close() }) @@ -312,10 +314,37 @@ describe('DownloadsService download progress persistence', () => { itemId: 'movie:1', qbCategory: 'jack-x', qbSourceServer: 'My Radarr', + sourceServerId: 'radarr-1', }) expect(result).toBe('failed') expect(repository.list()).toHaveLength(0) handle.close() }) + + test('startDirectDownload creates a jack_manual row carrying the importTarget and ends import_queued', async () => { + const handle = await openDatabase({ appConfigPath: join(tempDir, 'config.jsonc') }) + const repository = new DownloadsRepository(handle.db) + const service = new DownloadsService(downloadsConfig(), { peers: [fakePeer() as any] }, repository) + + const result = await service.startDirectDownload({ + peerId: 'peer-1', + itemId: 'movie:1', + destinationServerName: 'My Radarr', + destinationServerId: 'radarr-1', + importTarget: { kind: 'movie', movieId: 42 }, + }) + + expect(result).toBe('started') + await waitForStatus(repository, 'import_queued') + + const rows = repository.list() + expect(rows).toHaveLength(1) + expect(rows[0]?.status).toBe('import_queued') + expect(rows[0]?.qbSourceServer).toBe('My Radarr') + expect(rows[0]?.sourceServerId).toBe('radarr-1') + expect(rows[0]?.importMode).toBe('jack_manual') + expect(rows[0]?.importTarget).toEqual({ kind: 'movie', movieId: 42 }) + handle.close() + }) }) diff --git a/apps/backend/src/__tests__/import-watcher.test.ts b/apps/backend/src/__tests__/import-watcher.test.ts index 90fc70e..1ee12c0 100644 --- a/apps/backend/src/__tests__/import-watcher.test.ts +++ b/apps/backend/src/__tests__/import-watcher.test.ts @@ -1,9 +1,12 @@ +import type { ArrServerConnector, ManualImportParams } from '../lib/servers/arr/base' import type { DownloadsRepository } from '../modules/downloads/downloads.repository' +import { dirname } from 'node:path' import { Database } from 'bun:sqlite' -import { describe, expect, test } from 'bun:test' +import { describe, expect, mock, test } from 'bun:test' import { drizzle } from 'drizzle-orm/bun-sqlite' import { runMigrations } from '../database/connection' import * as schema from '../database/schema' +import { PermanentManualImportError } from '../lib/servers/arr/base' import { DownloadsRepository as Repo } from '../modules/downloads/downloads.repository' import { ImportWatcher } from '../modules/downloads/import-watcher' import { deriveHash } from '../modules/qbittorrent/qbittorrent.mapper' @@ -92,3 +95,215 @@ describe('ImportWatcher', () => { expect(repo.get(row.id)?.status).toBe('import_queued') }) }) + +// A jack_manual import_queued row: the watcher must push an explicit ManualImport +// to *arr (vs. the qB-added rows above, which *arr imports on its own). +function manualRow(repo: DownloadsRepository, qbSourceServer: string) { + const row = repo.create({ + torrentFilename: 't.torrent', + peerId: 'peer-1', + peerName: 'Friend', + itemId: 'movie:1', + filename: release.filename, + destPath: '/tmp/movies/x.mkv', + partPath: '/tmp/movies/x.mkv.part', + releaseSize: release.size, + release, + qbSourceServer, + importMode: 'jack_manual', + importTarget: { kind: 'movie', movieId: 42 }, + }) + repo.markImportQueued(row.id) + return row +} + +function manualServer(name: string, importedHashes: string[], manualImport: (params: ManualImportParams) => Promise, opts: { initialized?: boolean } = {}): ArrServerConnector { + return { + id: name, + name, + isInitialized: opts.initialized ?? true, + recentlyImportedDownloadIds: async () => new Set(importedHashes.map(h => h.toLowerCase())), + manualImport, + manualImportCommandStatus: async () => ({ state: 'pending' as const }), + } as unknown as ArrServerConnector +} + +describe('ImportWatcher jack_manual trigger', () => { + test('pushes manualImport once across two ticks while the hash is absent from history', async () => { + const repo = makeRepo() + const row = manualRow(repo, 'My Radarr') + const manualImport = mock(async () => 101) + const watcher = new ImportWatcher(repo, { servers: [manualServer('My Radarr', [], manualImport)] }, 1000) + + await watcher.tick() + await watcher.tick() + + expect(manualImport).toHaveBeenCalledTimes(1) + expect(manualImport).toHaveBeenCalledWith({ + folder: dirname(row.destPath), + paths: [row.destPath], + target: { kind: 'movie', movieId: 42 }, + downloadId: HASH, + release, + }) + expect(repo.get(row.id)?.status).toBe('import_queued') + }) + + test('marks the row imported (and skips the push) once the hash appears in *arr history', async () => { + const repo = makeRepo() + const row = manualRow(repo, 'My Radarr') + const manualImport = mock(async () => 102) + const watcher = new ImportWatcher(repo, { servers: [manualServer('My Radarr', [HASH], manualImport)] }, 1000) + + expect(await watcher.tick()).toBe(1) + expect(repo.get(row.id)?.status).toBe('imported') + expect(manualImport).not.toHaveBeenCalled() + }) + + test('does not re-trigger after a restart once the manual import command id is persisted', async () => { + const repo = makeRepo() + manualRow(repo, 'My Radarr') + const manualImport = mock(async () => 103) + const first = new ImportWatcher(repo, { servers: [manualServer('My Radarr', [], manualImport)] }, 1000) + await first.tick() + expect(manualImport).toHaveBeenCalledTimes(1) + + const second = new ImportWatcher(repo, { servers: [manualServer('My Radarr', [], manualImport)] }, 1000) + await second.tick() + expect(manualImport).toHaveBeenCalledTimes(1) + }) + + test('retries on the next tick when the manual-import push throws', async () => { + const repo = makeRepo() + manualRow(repo, 'My Radarr') + let calls = 0 + const manualImport = mock(async () => { + calls++ + if (calls === 1) + throw new Error('arr down') + return 104 + }) + const watcher = new ImportWatcher(repo, { servers: [manualServer('My Radarr', [], manualImport)] }, 1000) + + await watcher.tick() + await watcher.tick() + expect(manualImport).toHaveBeenCalledTimes(2) + }) +}) + +type TestManualImportCommandState + = | { state: 'pending' } + | { state: 'completed' } + | { state: 'failed', error: string } + +interface ImportWatcherServer { + id: string + name: string + isInitialized: boolean + recentlyImportedDownloadIds: () => Promise> + manualImport: (params: ManualImportParams) => Promise + manualImportCommandStatus: (commandId: number) => Promise +} + +function watcherWithServers(repo: DownloadsRepository, servers: ImportWatcherServer[]) { + // Test fakes implement only the ArrServerConnector surface that ImportWatcher calls. + return new ImportWatcher(repo, { servers: servers as unknown as ArrServerConnector[] }, 1000) +} + +function manualImportRow(repo: DownloadsRepository, input: { qbSourceServer: string, sourceServerId: string }) { + const row = repo.create({ + torrentFilename: 't.torrent', + peerId: 'peer-1', + peerName: 'Friend', + itemId: 'movie:1', + filename: release.filename, + destPath: '/tmp/movies/x.mkv', + partPath: '/tmp/movies/x.mkv.part', + releaseSize: release.size, + release, + qbSourceServer: input.qbSourceServer, + sourceServerId: input.sourceServerId, + importMode: 'jack_manual', + importTarget: { kind: 'movie', movieId: 42 }, + }) + repo.markImportQueued(row.id) + return row +} + +describe('ImportWatcher tracked manual imports', () => { + test('resolves queued rows by stable server id after a destination rename', async () => { + const repo = makeRepo() + const row = manualImportRow(repo, { qbSourceServer: 'Old Radarr', sourceServerId: 'radarr-1' }) + const server: ImportWatcherServer = { + id: 'radarr-1', + name: 'New Radarr', + isInitialized: true, + recentlyImportedDownloadIds: async () => new Set(), + manualImport: async () => 41, + manualImportCommandStatus: async () => ({ state: 'pending' }), + } + const watcher = watcherWithServers(repo, [server]) + + await watcher.tick() + + expect(repo.get(row.id)?.manualImportCommandId).toBe(41) + }) + + test('marks a manual import row imported when the tracked command completes', async () => { + const repo = makeRepo() + const row = manualImportRow(repo, { qbSourceServer: 'My Radarr', sourceServerId: 'radarr-1' }) + const server: ImportWatcherServer = { + id: 'radarr-1', + name: 'My Radarr', + isInitialized: true, + recentlyImportedDownloadIds: async () => new Set(), + manualImport: async () => 42, + manualImportCommandStatus: async () => ({ state: 'completed' }), + } + const watcher = watcherWithServers(repo, [server]) + + await watcher.tick() + await watcher.tick() + + expect(repo.get(row.id)?.status).toBe('imported') + }) + + test('marks a manual import row failed when the tracked command fails', async () => { + const repo = makeRepo() + const row = manualImportRow(repo, { qbSourceServer: 'My Radarr', sourceServerId: 'radarr-1' }) + const server: ImportWatcherServer = { + id: 'radarr-1', + name: 'My Radarr', + isInitialized: true, + recentlyImportedDownloadIds: async () => new Set(), + manualImport: async () => 43, + manualImportCommandStatus: async () => ({ state: 'failed', error: 'manual import rejected' }), + } + const watcher = watcherWithServers(repo, [server]) + + await watcher.tick() + await watcher.tick() + + expect(repo.get(row.id)).toMatchObject({ status: 'failed', error: 'manual import rejected' }) + }) + + test('marks a manual import row failed when the connector reports a permanent import error', async () => { + const repo = makeRepo() + const row = manualImportRow(repo, { qbSourceServer: 'My Radarr', sourceServerId: 'radarr-1' }) + const server: ImportWatcherServer = { + id: 'radarr-1', + name: 'My Radarr', + isInitialized: true, + recentlyImportedDownloadIds: async () => new Set(), + manualImport: async () => { + throw new PermanentManualImportError('episode ids could not be resolved') + }, + manualImportCommandStatus: async () => ({ state: 'pending' }), + } + const watcher = watcherWithServers(repo, [server]) + + await watcher.tick() + + expect(repo.get(row.id)).toMatchObject({ status: 'failed', error: 'episode ids could not be resolved' }) + }) +}) diff --git a/apps/backend/src/__tests__/integration.test.ts b/apps/backend/src/__tests__/integration.test.ts index 79798cc..40acf77 100644 --- a/apps/backend/src/__tests__/integration.test.ts +++ b/apps/backend/src/__tests__/integration.test.ts @@ -13,6 +13,8 @@ import { RadarrServerConnector } from '../lib/servers/arr/radarr' import { SonarrServerConnector } from '../lib/servers/arr/sonarr' import { PeerConnector } from '../lib/servers/peer' import { DownloadsRepository } from '../modules/downloads/downloads.repository' +import { ManagedKeysRepository } from '../modules/managed-keys/managed-keys.repository' +import { ManagedApiKeys } from '../modules/managed-keys/managed-keys.service' const RADARR_URL = 'http://radarr.test:7878' const SONARR_URL = 'http://sonarr.test:8989' @@ -306,6 +308,34 @@ describe('Torrent download', () => { const res = await app.request(`/torznab/download/${encodeURIComponent(guid)}.torrent`) expect(res.status).toBe(401) }) + + // Production scenario: the main key is unset and Radarr authenticates the indexer + // with a managed key. The feed must embed THAT managed key in each download URL, + // and grabbing the .torrent with it must succeed (regression for the 401-on-grab). + test('feed embeds the requester managed key, and the grab round-trips', async () => { + const database = new Database(':memory:') + testDatabases.push(database) + database.exec('pragma foreign_keys = ON') + const db = drizzle({ client: database, schema }) + runMigrations(db) + const downloadsRepository = new DownloadsRepository(db) + const managedKeysRepository = new ManagedKeysRepository(db) + + const radarr = markInitialized(makeRadarr()) + const peer = markInitialized(new PeerConnector({ url: PEER_JACK_URL, apiKey: 'peer-api-key', name: 'Friend Jack' })) + const { key: managedKey } = new ManagedApiKeys(managedKeysRepository).provision(radarr.id) + + const app = getApp(envs, config, { servers: [radarr], peers: [peer] }, { downloadsRepository, managedKeysRepository }) + + const feed = await (await app.request(`/torznab/api?t=movie&tmdbid=603&apikey=${managedKey}`)).text() + expect(feed).toContain(peerRelease.title) + expect(feed).toContain(`apikey=${managedKey}`) + expect(feed).not.toContain('apikey=test-api-key') + + const guid = `${peer.id}:${peerRelease.id}` + const grab = await app.request(`/torznab/download/${encodeURIComponent(guid)}.torrent?apikey=${managedKey}`) + expect(grab.status).toBe(200) + }) }) describe('Downloads API', () => { diff --git a/apps/backend/src/__tests__/qbittorrent-api.test.ts b/apps/backend/src/__tests__/qbittorrent-api.test.ts index c142f7f..98a0521 100644 --- a/apps/backend/src/__tests__/qbittorrent-api.test.ts +++ b/apps/backend/src/__tests__/qbittorrent-api.test.ts @@ -67,6 +67,7 @@ function seedDownload(repository: DownloadsRepository, category: string) { release: { id: 'conn:movie:42', title: 'Big Buck Bunny', filename: 'Big Buck Bunny (2008).mkv', category: 2000, size: 10 } as any, qbCategory: category, qbSourceServer: 'My Radarr', + sourceServerId: 'abc12345', }) } @@ -220,6 +221,7 @@ describe('qBittorrent add/delete/setCategory', () => { expect(calls[0].itemId).toBe('conn:movie:42') expect(calls[0].qbCategory).toBe('jack-abc12345') expect(calls[0].qbSourceServer).toBe('My Radarr') + expect(calls[0].sourceServerId).toBe('abc12345') }) test('add returns 503 when startQbDownload fails so *arr retries promptly', async () => { diff --git a/apps/backend/src/__tests__/qbittorrent-mapper.test.ts b/apps/backend/src/__tests__/qbittorrent-mapper.test.ts index 1744d01..7d37c25 100644 --- a/apps/backend/src/__tests__/qbittorrent-mapper.test.ts +++ b/apps/backend/src/__tests__/qbittorrent-mapper.test.ts @@ -26,6 +26,10 @@ function baseRecord(overrides: Partial = {}): DownloadRecord { error: null, qbCategory: 'jack-abc12345', qbSourceServer: 'My Radarr', + sourceServerId: 'abc12345', + importMode: null, + importTarget: null, + manualImportCommandId: null, ...overrides, } } diff --git a/apps/backend/src/__tests__/tmdb-client.test.ts b/apps/backend/src/__tests__/tmdb-client.test.ts new file mode 100644 index 0000000..38adef1 --- /dev/null +++ b/apps/backend/src/__tests__/tmdb-client.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from 'bun:test' +import { buildImageUrl, mapTmdbDetail } from '../lib/tmdb/client' + +describe('buildImageUrl', () => { + test('assembles a full image url from a poster path with the default size', () => { + expect(buildImageUrl('/abc.jpg')).toBe('https://image.tmdb.org/t/p/w500/abc.jpg') + }) + + test('honors a custom size', () => { + expect(buildImageUrl('/abc.jpg', 'w780')).toBe('https://image.tmdb.org/t/p/w780/abc.jpg') + }) + + test('returns null for a null path', () => { + expect(buildImageUrl(null)).toBeNull() + }) + + test('returns null for an undefined path', () => { + expect(buildImageUrl(undefined)).toBeNull() + }) +}) + +describe('mapTmdbDetail', () => { + test('maps a movie detail (title/release_date/vote_average)', () => { + const meta = mapTmdbDetail({ + id: 550, + title: 'Fight Club', + overview: 'A man and his alter ego.', + release_date: '1999-10-15', + vote_average: 8.4, + poster_path: '/poster.jpg', + backdrop_path: '/backdrop.jpg', + genres: [{ id: 18, name: 'Drama' }], + }) + + expect(meta.tmdbId).toBe(550) + expect(meta.title).toBe('Fight Club') + expect(meta.year).toBe(1999) + expect(meta.rating).toBe(8.4) + expect(meta.overview).toBe('A man and his alter ego.') + expect(meta.posterUrl).toBe('https://image.tmdb.org/t/p/w500/poster.jpg') + expect(meta.backdropUrl).toBe('https://image.tmdb.org/t/p/w780/backdrop.jpg') + expect(meta.genres).toEqual(['Drama']) + }) + + test('maps a tv detail from name/first_air_date when title/release_date are absent', () => { + const meta = mapTmdbDetail({ + id: 1396, + name: 'Breaking Bad', + first_air_date: '2008-01-20', + vote_average: 8.9, + }) + + expect(meta.title).toBe('Breaking Bad') + expect(meta.year).toBe(2008) + expect(meta.rating).toBe(8.9) + }) + + test('falls back to null year/rating and empty genres when fields are missing', () => { + const meta = mapTmdbDetail({ id: 1 }) + + expect(meta.title).toBe('Untitled') + expect(meta.year).toBeNull() + expect(meta.rating).toBeNull() + expect(meta.posterUrl).toBeNull() + expect(meta.backdropUrl).toBeNull() + expect(meta.genres).toEqual([]) + expect(meta.overview).toBeNull() + }) +}) diff --git a/apps/backend/src/__tests__/torznab.test.ts b/apps/backend/src/__tests__/torznab.test.ts index b44189d..33ec893 100644 --- a/apps/backend/src/__tests__/torznab.test.ts +++ b/apps/backend/src/__tests__/torznab.test.ts @@ -1,6 +1,7 @@ import type { Release } from '../lib/release' +import type { PeerConnector } from '../lib/servers/peer' import { describe, expect, test } from 'bun:test' -import { releaseToTorznab } from '../modules/torznab/torznab.controller' +import { releaseToTorznab, TorznabController } from '../modules/torznab/torznab.controller' import { buildErrorXml, buildSearchResultXml } from '../modules/torznab/torznab.router' const movieRelease: Release = { @@ -53,6 +54,16 @@ describe('Torznab XML helpers', () => { expect(attrValue(item, 'uploadvolumefactor')).toBe(1) }) + test('buildSearchResultXml tags every item as an internal release', () => { + // *arr's TorznabRssParser maps `tag=internal` to the Internal indexer flag, + // so a custom format (IndexerFlagSpecification) can target Jack releases. + const result = buildSearchResultXml([releaseToTorznab(movieRelease, 'peer1', 'Friend', 'http://localhost:3000', JACK_API_KEY)]) + const tags = (result.rss.channel.item[0]['torznab:attr'] as Array>) + .filter(a => a['@name'] === 'tag') + .map(a => a['@value']) + expect(tags).toContain('internal') + }) + test('buildSearchResultXml emits tv attrs for episodes', () => { const result = buildSearchResultXml([releaseToTorznab(episodeRelease, 'peer1', 'Friend', 'http://localhost:3000', JACK_API_KEY)]) const item = result.rss.channel.item[0] @@ -87,6 +98,43 @@ describe('Torznab XML helpers', () => { }) }) +describe('TorznabController embeds the requester key in download URLs', () => { + // The main key is deprecated/often unset; the requester (Radarr) authenticates + // the indexer with a managed key. Each download URL must carry THAT key so the + // grab passes auth — not the main key, which would 401 when unset. + const jackConfig = { internalUrl: 'http://localhost:3000', apiKey: 'main-key' } as any + + function fakePeer(releases: Release[]): PeerConnector { + return { + id: 'peer1', + name: 'Friend', + searchByTmdbId: async () => releases, + searchByImdbId: async () => releases, + searchByTvdbId: async () => releases, + listReleases: async () => releases, + } as unknown as PeerConnector + } + + test('searchMovie embeds the passed request key, not the main key', async () => { + const controller = new TorznabController(() => [fakePeer([movieRelease])], jackConfig) + const items = await controller.searchMovie({ tmdbId: '12345' }, 'jack_managed_abc') + expect(items).toHaveLength(1) + expect(new URL(items[0]!.downloadUrl).searchParams.get('apikey')).toBe('jack_managed_abc') + }) + + test('searchTv embeds the passed request key', async () => { + const controller = new TorznabController(() => [fakePeer([episodeRelease])], jackConfig) + const items = await controller.searchTv('654321', 1, 2, 'jack_managed_tv') + expect(new URL(items[0]!.downloadUrl).searchParams.get('apikey')).toBe('jack_managed_tv') + }) + + test('catalog embeds the passed request key', async () => { + const controller = new TorznabController(() => [fakePeer([movieRelease])], jackConfig) + const items = await controller.catalog('jack_managed_xyz') + expect(new URL(items[0]!.downloadUrl).searchParams.get('apikey')).toBe('jack_managed_xyz') + }) +}) + describe('releaseToTorznab', () => { test('maps a movie release', () => { const result = releaseToTorznab(movieRelease, 'peer1', 'Friend', 'http://localhost:3000', JACK_API_KEY) diff --git a/apps/backend/src/database/schema.ts b/apps/backend/src/database/schema.ts index dc58ceb..d808f92 100644 --- a/apps/backend/src/database/schema.ts +++ b/apps/backend/src/database/schema.ts @@ -33,6 +33,13 @@ export const downloads = sqliteTable('downloads', { // download (→ *arr-pull import, no jack push). Null for blackhole-added rows. qbCategory: text('qb_category'), qbSourceServer: text('qb_source_server'), + sourceServerId: text('source_server_id'), + // Direct catalog downloads: 'jack_manual' means the import watcher must push an + // explicit *arr ManualImport (vs. null = qB/blackhole, where *arr imports itself). + importMode: text('import_mode').$type<'jack_manual' | null>(), + // JSON ManualImportTarget: {"kind":"movie","movieId":N} | {"kind":"series","seriesId":N}. + importTarget: text('import_target'), + manualImportCommandId: integer('manual_import_command_id'), }, t => [ check('downloads_status_check', sql`${t.status} in ('downloading', 'import_queued', 'imported', 'failed')`), check('downloads_expected_bytes_source_check', sql`${t.expectedBytesSource} is null or ${t.expectedBytesSource} in ('content_length', 'content_range', 'release_size')`), diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 5f52a37..4bd272c 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -82,7 +82,9 @@ function startManagementServer() { connectors: connectorManager, configService, downloadsRepository, + downloadsService, apiKeysRepository, + tmdbApiKey: config.jack.tmdbApiKey, }) const instance = Bun.serve({ port: envs.MANAGEMENT_PORT, fetch: managementApp.fetch }) logger.info({ port: instance.port }, 'Management API listening') @@ -112,15 +114,13 @@ for (const dest of registrable) { internalUrl: jackConfig.internalUrl, downloads: Boolean(downloads), category: qbCategoryForServer(dest.id), - onSuccess: (kind, name, meta) => - logger.info( - kind === 'download client' - ? { destination: name, downloadClientId: meta.downloadClientId } - : { destination: name, categories: meta.categories, downloadClientId: meta.downloadClientId }, - kind === 'download client' - ? 'Registered Jack as qBittorrent download client' - : 'Registered Jack as Torznab indexer', - ), + onSuccess: (kind, name, meta) => { + if (kind === 'download client') { + logger.info({ destination: name, downloadClientId: meta.downloadClientId }, 'Registered Jack as qBittorrent download client') + return + } + logger.info({ destination: name, categories: meta.categories, downloadClientId: meta.downloadClientId }, 'Registered Jack as Torznab indexer') + }, onFailure: logRegistrationFailure, }) } diff --git a/apps/backend/src/lib/config.ts b/apps/backend/src/lib/config.ts index 3fead06..d4a1e76 100644 --- a/apps/backend/src/lib/config.ts +++ b/apps/backend/src/lib/config.ts @@ -155,6 +155,8 @@ export const JackConfig = z.object({ // only an internalUrl, in which case the public API authenticates via generated // keys (see require-auth.ts), not this key. apiKey: ConfigSecret().optional(), + // Optional TMDB v3 API key for enriching peer catalogs with artwork/metadata. + tmdbApiKey: ConfigSecret().optional(), }) export type JackConfig = z.infer @@ -164,6 +166,7 @@ export type JackConfig = z.infer export const RawJackConfig = z.object({ internalUrl: z.url(), apiKey: RawConfigSecret.optional(), + tmdbApiKey: RawConfigSecret.optional(), }) export type RawJackConfig = z.infer diff --git a/apps/backend/src/lib/servers/arr/arr-add-import.test.ts b/apps/backend/src/lib/servers/arr/arr-add-import.test.ts new file mode 100644 index 0000000..50ed5c0 --- /dev/null +++ b/apps/backend/src/lib/servers/arr/arr-add-import.test.ts @@ -0,0 +1,371 @@ +import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { z } from 'zod' +import { BadRequestError } from '../../errors/BadRequestError' +import { RadarrServerConnector } from './radarr' +import { SonarrServerConnector } from './sonarr' + +const RADARR_URL = 'http://radarr.test:7878' +const SONARR_URL = 'http://sonarr.test:8989' +const HEX_KEY = 'a'.repeat(32) +const AUTOREGISTER = { enable: true, priority: 1 } + +// Default handlers shared by every test: identity ping (drives the +// @requiresInitialization guard's auto-init) + a single quality profile. +const handlers = [ + http.get(`${RADARR_URL}/api/v3/system/status`, () => HttpResponse.json({ appName: 'Radarr', version: '4.0.0' })), + http.get(`${SONARR_URL}/api/v3/system/status`, () => HttpResponse.json({ appName: 'Sonarr', version: '4.0.0' })), + http.get(`${RADARR_URL}/api/v3/qualityprofile`, () => HttpResponse.json([{ id: 4 }])), + http.get(`${SONARR_URL}/api/v3/qualityprofile`, () => HttpResponse.json([{ id: 7 }])), +] + +const server = setupServer(...handlers) + +beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' })) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) + +function makeRadarr() { + return new RadarrServerConnector({ + url: RADARR_URL, + apiKey: HEX_KEY, + name: 'My Radarr', + source: true, + destination: true, + autoregister: AUTOREGISTER, + }) +} + +function makeSonarr() { + return new SonarrServerConnector({ + url: SONARR_URL, + apiKey: HEX_KEY, + name: 'My Sonarr', + source: true, + destination: true, + autoregister: AUTOREGISTER, + }) +} + +// The candidate `quality`/`languages` are opaque blobs we forward verbatim, so a +// schema is the rule-compliant way to read the captured POST body back as typed. +const CommandBody = z.object({ + name: z.string(), + importMode: z.string(), + files: z.array(z.object({ + path: z.string(), + movieId: z.number().optional(), + seriesId: z.number().optional(), + episodeIds: z.array(z.number()).optional(), + downloadId: z.string(), + quality: z.unknown(), + languages: z.array(z.unknown()), + releaseGroup: z.string().optional(), + })), +}) + +const lookedUpMovie = { tmdbId: 603, title: 'The Matrix', year: 1999, titleSlug: 'the-matrix-603', images: [] } +const lookedUpSeries = { tvdbId: 81189, title: 'Breaking Bad', year: 2008, titleSlug: 'breaking-bad-81189', images: [] } + +describe('RadarrServerConnector.add', () => { + test('looks up the movie and POSTs it WITHOUT a search, returning the created id', async () => { + let postBody: unknown = null + let lookupTerm: string | null = null + server.use( + http.get(`${RADARR_URL}/api/v3/movie`, () => HttpResponse.json([])), + http.get(`${RADARR_URL}/api/v3/movie/lookup`, ({ request }) => { + lookupTerm = new URL(request.url).searchParams.get('term') + return HttpResponse.json([lookedUpMovie]) + }), + http.post(`${RADARR_URL}/api/v3/movie`, async ({ request }) => { + postBody = await request.json() + return HttpResponse.json({ id: 1, ...lookedUpMovie }) + }), + ) + + const radarr = makeRadarr() + const id = await radarr.add({ tmdbId: 603, rootFolderPath: '/movies' }) + + expect(id).toBe(1) + expect(lookupTerm).toBe('tmdb:603') + expect(postBody).toMatchObject({ + tmdbId: 603, + title: 'The Matrix', + qualityProfileId: 4, + rootFolderPath: '/movies', + monitored: true, + addOptions: { searchForMovie: false }, + }) + }) + + test('is idempotent: returns the existing movie id without POSTing when already in the library', async () => { + let posted = false + server.use( + http.get(`${RADARR_URL}/api/v3/movie`, () => HttpResponse.json([{ id: 99 }])), + http.post(`${RADARR_URL}/api/v3/movie`, () => { + posted = true + return HttpResponse.json({ id: 99 }) + }), + ) + + const radarr = makeRadarr() + const id = await radarr.add({ tmdbId: 603, rootFolderPath: '/movies' }) + + expect(id).toBe(99) + expect(posted).toBe(false) + }) + + test('throws BadRequestError when no tmdbId is given', async () => { + const radarr = makeRadarr() + await expect(radarr.add({ rootFolderPath: '/movies' })).rejects.toThrow(BadRequestError) + }) + + test('throws BadRequestError naming the server and id when the lookup is empty', async () => { + server.use( + http.get(`${RADARR_URL}/api/v3/movie`, () => HttpResponse.json([])), + http.get(`${RADARR_URL}/api/v3/movie/lookup`, () => HttpResponse.json([])), + ) + const radarr = makeRadarr() + const promise = radarr.add({ tmdbId: 603, rootFolderPath: '/movies' }) + await expect(promise).rejects.toThrow(BadRequestError) + await expect(promise).rejects.toThrow(/My Radarr/) + await expect(promise).rejects.toThrow(/603/) + }) +}) + +describe('RadarrServerConnector.manualImport', () => { + test('imports only the file matching params.paths, carrying movieId/downloadId/quality/languages', async () => { + let commandBody: unknown = null + server.use( + http.get(`${RADARR_URL}/api/v3/manualimport`, () => HttpResponse.json([ + { path: '/downloads/Movie.mkv', quality: { quality: { id: 7 } }, languages: [{ id: 1, name: 'English' }], releaseGroup: 'GRP' }, + { path: '/downloads/Other.mkv', quality: { quality: { id: 3 } }, languages: [{ id: 2, name: 'French' }], releaseGroup: 'X' }, + ])), + http.post(`${RADARR_URL}/api/v3/command`, async ({ request }) => { + commandBody = await request.json() + return HttpResponse.json({ id: 1 }) + }), + ) + + const radarr = makeRadarr() + const commandId = await radarr.manualImport({ + folder: '/downloads', + paths: ['/downloads/Movie.mkv'], + target: { kind: 'movie', movieId: 7 }, + downloadId: 'dl-1', + }) + + const body = CommandBody.parse(commandBody) + expect(commandId).toBe(1) + expect(body.name).toBe('ManualImport') + expect(body.importMode).toBe('move') + expect(body.files).toHaveLength(1) + expect(body.files[0]).toMatchObject({ + path: '/downloads/Movie.mkv', + movieId: 7, + downloadId: 'dl-1', + quality: { quality: { id: 7 } }, + languages: [{ id: 1, name: 'English' }], + releaseGroup: 'GRP', + }) + }) + + test('throws BadRequestError when given a series target', async () => { + const radarr = makeRadarr() + await expect(radarr.manualImport({ + folder: '/downloads', + paths: ['/downloads/Movie.mkv'], + target: { kind: 'series', seriesId: 1 }, + downloadId: 'dl-1', + })).rejects.toThrow(BadRequestError) + }) + + test('throws BadRequestError when no candidate matches the wanted path', async () => { + server.use( + http.get(`${RADARR_URL}/api/v3/manualimport`, () => HttpResponse.json([ + { path: '/downloads/Other.mkv', quality: {}, languages: [] }, + ])), + ) + const radarr = makeRadarr() + await expect(radarr.manualImport({ + folder: '/downloads', + paths: ['/downloads/Movie.mkv'], + target: { kind: 'movie', movieId: 7 }, + downloadId: 'dl-1', + })).rejects.toThrow(BadRequestError) + }) +}) + +describe('SonarrServerConnector.add', () => { + test('looks up the series and POSTs it WITHOUT a search, returning the created id', async () => { + let postBody: unknown = null + let lookupTerm: string | null = null + server.use( + http.get(`${SONARR_URL}/api/v3/series`, () => HttpResponse.json([])), + http.get(`${SONARR_URL}/api/v3/series/lookup`, ({ request }) => { + lookupTerm = new URL(request.url).searchParams.get('term') + return HttpResponse.json([lookedUpSeries]) + }), + http.post(`${SONARR_URL}/api/v3/series`, async ({ request }) => { + postBody = await request.json() + return HttpResponse.json({ id: 1, ...lookedUpSeries }) + }), + ) + + const sonarr = makeSonarr() + const id = await sonarr.add({ tvdbId: 81189, rootFolderPath: '/tv' }) + + expect(id).toBe(1) + expect(lookupTerm).toBe('tvdb:81189') + expect(postBody).toMatchObject({ + tvdbId: 81189, + title: 'Breaking Bad', + qualityProfileId: 7, + rootFolderPath: '/tv', + monitored: true, + seasonFolder: true, + addOptions: { monitor: 'all', searchForMissingEpisodes: false }, + }) + }) + + test('is idempotent: returns the existing series id without POSTing when already in the library', async () => { + let posted = false + server.use( + http.get(`${SONARR_URL}/api/v3/series`, () => HttpResponse.json([{ id: 42 }])), + http.post(`${SONARR_URL}/api/v3/series`, () => { + posted = true + return HttpResponse.json({ id: 42 }) + }), + ) + + const sonarr = makeSonarr() + const id = await sonarr.add({ tvdbId: 81189, rootFolderPath: '/tv' }) + + expect(id).toBe(42) + expect(posted).toBe(false) + }) + + test('throws BadRequestError when no tvdbId is given', async () => { + const sonarr = makeSonarr() + await expect(sonarr.add({ rootFolderPath: '/tv' })).rejects.toThrow(BadRequestError) + }) +}) + +describe('SonarrServerConnector.manualImport', () => { + test('imports the matching file carrying seriesId, episodeIds, downloadId, quality, languages', async () => { + let commandBody: unknown = null + server.use( + http.get(`${SONARR_URL}/api/v3/manualimport`, () => HttpResponse.json([ + { path: '/downloads/Ep.mkv', quality: { quality: { id: 4 } }, languages: [{ id: 1, name: 'English' }], releaseGroup: 'GRP', episodes: [{ id: 11 }, { id: 12 }] }, + { path: '/downloads/Other.mkv', quality: {}, languages: [], episodes: [{ id: 99 }] }, + ])), + http.post(`${SONARR_URL}/api/v3/command`, async ({ request }) => { + commandBody = await request.json() + return HttpResponse.json({ id: 1 }) + }), + ) + + const sonarr = makeSonarr() + + const commandId = await sonarr.manualImport({ + folder: '/downloads', + paths: ['/downloads/Ep.mkv'], + target: { kind: 'series', seriesId: 3 }, + downloadId: 'dl-2', + }) + + const body = CommandBody.parse(commandBody) + expect(commandId).toBe(1) + expect(body.name).toBe('ManualImport') + expect(body.importMode).toBe('move') + expect(body.files).toHaveLength(1) + expect(body.files[0]).toMatchObject({ + path: '/downloads/Ep.mkv', + seriesId: 3, + episodeIds: [11, 12], + downloadId: 'dl-2', + quality: { quality: { id: 4 } }, + languages: [{ id: 1, name: 'English' }], + }) + }) + + test('falls back to release season and episode when Sonarr cannot parse episode ids', async () => { + let commandBody: unknown = null + server.use( + http.get(`${SONARR_URL}/api/v3/manualimport`, () => HttpResponse.json([ + { path: '/downloads/Ep.mkv', quality: { quality: { id: 4 } }, languages: [{ id: 1, name: 'English' }], releaseGroup: 'GRP', episodes: [] }, + ])), + http.get(`${SONARR_URL}/api/v3/episode`, () => HttpResponse.json([ + { id: 22, seasonNumber: 1, episodeNumber: 2 }, + ])), + http.post(`${SONARR_URL}/api/v3/command`, async ({ request }) => { + commandBody = await request.json() + return HttpResponse.json({ id: 2 }) + }), + ) + + const sonarr = makeSonarr() + const commandId = await sonarr.manualImport({ + folder: '/downloads', + paths: ['/downloads/Ep.mkv'], + target: { kind: 'series', seriesId: 3 }, + downloadId: 'dl-2', + release: { season: 1, episode: 2 }, + }) + + const body = CommandBody.parse(commandBody) + expect(commandId).toBe(2) + expect(body.files[0]).toMatchObject({ + path: '/downloads/Ep.mkv', + seriesId: 3, + episodeIds: [22], + downloadId: 'dl-2', + }) + }) + + test('throws BadRequestError when given a movie target', async () => { + const sonarr = makeSonarr() + await expect(sonarr.manualImport({ + folder: '/downloads', + paths: ['/downloads/Ep.mkv'], + target: { kind: 'movie', movieId: 1 }, + downloadId: 'dl-2', + })).rejects.toThrow(BadRequestError) + }) +}) + +describe('manualImportCommandStatus', () => { + test('reports completed/failed/pending from the command status', async () => { + server.use( + http.get(`${RADARR_URL}/api/v3/command/1`, () => HttpResponse.json({ id: 1, status: 'completed' })), + http.get(`${RADARR_URL}/api/v3/command/2`, () => HttpResponse.json({ id: 2, status: 'failed', message: 'boom' })), + http.get(`${RADARR_URL}/api/v3/command/3`, () => HttpResponse.json({ id: 3, status: 'started' })), + ) + const radarr = makeRadarr() + expect(await radarr.manualImportCommandStatus(1)).toEqual({ state: 'completed' }) + expect(await radarr.manualImportCommandStatus(2)).toEqual({ state: 'failed', error: 'boom' }) + expect(await radarr.manualImportCommandStatus(3)).toEqual({ state: 'pending' }) + }) + + // A pruned command record (404) is terminal, not transient: returning `failed` + // lets the watcher fail the row instead of polling a vanished id forever. + test('returns failed when the command record was pruned (404)', async () => { + server.use( + http.get(`${RADARR_URL}/api/v3/command/9`, () => new HttpResponse(null, { status: 404 })), + ) + const radarr = makeRadarr() + const status = await radarr.manualImportCommandStatus(9) + expect(status.state).toBe('failed') + expect(status).toMatchObject({ error: expect.stringContaining('no longer exists') }) + }) + + // A transient failure (5xx, timeout) must keep throwing so the watcher retries. + test('rethrows non-404 errors so the watcher retries next tick', async () => { + server.use( + http.get(`${RADARR_URL}/api/v3/command/8`, () => new HttpResponse(null, { status: 503 })), + ) + const radarr = makeRadarr() + await expect(radarr.manualImportCommandStatus(8)).rejects.toThrow() + }) +}) diff --git a/apps/backend/src/lib/servers/arr/base.ts b/apps/backend/src/lib/servers/arr/base.ts index aa179fc..fe30cd3 100644 --- a/apps/backend/src/lib/servers/arr/base.ts +++ b/apps/backend/src/lib/servers/arr/base.ts @@ -4,6 +4,8 @@ import z from 'zod' import { logger } from '../../../logger' import { requiresDestination, requiresSource } from '../../decorators/requires-capability' import { requiresInitialization } from '../../decorators/requires-initialization' +import { BadRequestError } from '../../errors/BadRequestError' +import { FetchError } from '../../errors/FetchError' import { ServerConnector } from '../base' const BASENAME_SEPARATOR_REGEX = /[/\\]/ @@ -32,6 +34,11 @@ export const DestinationServerHealthIssue = z.array( // *arr returns the saved download client on create; we only need its id to bind // the auto-registered indexer to it. const DownloadClientResource = z.object({ id: z.number().int() }) +const CommandResource = z.object({ + id: z.number().int().optional(), + status: z.string().nullable().optional(), + message: z.string().nullable().optional(), +}) // Register the Jack client at *arr's lowest selectable priority (the UI caps it // at 50). *arr's general client pool only round-robins among the best-priority @@ -42,6 +49,40 @@ const JACK_DOWNLOAD_CLIENT_PRIORITY = 50 export type ReleaseKind = 'movie' | 'episode' +export interface AddParams { + tmdbId?: number + tvdbId?: number + rootFolderPath: string +} + +export type ManualImportTarget + = | { kind: 'movie', movieId: number } + | { kind: 'series', seriesId: number } + +export interface ManualImportParams { + /** Directory *arr scans for importable files (the downloads completedPath). */ + folder: string + /** Absolute paths of the file(s) we downloaded; only these are imported. */ + paths: string[] + target: ManualImportTarget + /** Stub infohash = deriveHash(title,size); recorded in *arr history so the watcher matches it. */ + downloadId: string + /** Original release numbering, used as a Sonarr fallback when filename parsing omits episode ids. */ + release?: Pick +} + +export type ManualImportCommandState + = | { state: 'pending' } + | { state: 'completed' } + | { state: 'failed', error: string } + +export class PermanentManualImportError extends Error { + constructor(message: string) { + super(message) + this.name = 'PermanentManualImportError' + } +} + export function basename(path: string): string { return path.split(BASENAME_SEPARATOR_REGEX).pop() ?? path } @@ -82,6 +123,9 @@ export abstract class ArrServerConnector extends ServerConnector { abstract get categories(): number[] // qBittorrent settings use a per-app category field name. protected abstract get qbCategoryFieldName(): string + // *arr's IndexerFlagSpecification value for the "Internal" flag differs per app + // (Sonarr = 8, Radarr = 32); a single hardcoded value would silently mismatch. + protected abstract get internalIndexerFlagValue(): number protected override async runInit(): Promise { const apiInfo = await this.ping(z.object({ appName: z.string(), version: z.string() })) @@ -171,6 +215,70 @@ export abstract class ArrServerConnector extends ServerConnector { return this.fetch('/api/v3/health', { schema: z.array(DestinationServerHealthIssue) }) } + @requiresDestination + @requiresInitialization + async getRootFolders(): Promise> { + const folders = await this.arrGet>('/api/v3/rootfolder') + return (Array.isArray(folders) ? folders : []) + .filter(f => typeof f.path === 'string') + .map(f => ({ path: f.path, freeSpace: f.freeSpace })) + } + + /** Add a title to this *arr (monitored) WITHOUT a search; returns the created or existing entity id. */ + @requiresDestination + @requiresInitialization + async add(params: AddParams): Promise { + return this.doAdd(params) + } + + protected abstract doAdd(params: AddParams): Promise + + /** Import already-downloaded file(s) into this *arr, mapped to `target`. */ + @requiresDestination + @requiresInitialization + async manualImport(params: ManualImportParams): Promise { + return this.doManualImport(params) + } + + protected abstract doManualImport(params: ManualImportParams): Promise + + /** Current state for a previously accepted ManualImport command. */ + @requiresDestination + @requiresInitialization + async manualImportCommandStatus(commandId: number): Promise { + let command: z.infer + try { + command = await this.fetch(`/api/v3/command/${commandId}`, { method: 'GET', schema: CommandResource }) + } + catch (err) { + // *arr prunes command records after a while, so a 404 means this command is + // gone for good. Treat it as terminal (rather than re-throwing every tick) so + // the watcher fails the row instead of polling a vanished command id forever. + // A real success is already caught earlier by the import-history match, so we + // only reach here when the file never imported. + if (err instanceof FetchError && err.response.status === 404) + return { state: 'failed', error: `Manual import command ${commandId} no longer exists on ${this.name}` } + throw err + } + const status = command.status?.toLowerCase() + if (status === 'completed') + return { state: 'completed' } + if (status === 'failed' || status === 'aborted' || status === 'cancelled') { + const message = command.message ?? `Manual import command ${commandId} ${status}` + return { state: 'failed', error: message } + } + return { state: 'pending' } + } + + /** First quality profile id — required to add a title (manual import detects real quality from the file). */ + protected async resolveQualityProfileId(): Promise { + const profiles = await this.arrGet>('/api/v3/qualityprofile') + const first = Array.isArray(profiles) ? profiles[0] : undefined + if (!first) + throw new BadRequestError(`No quality profile found on ${this.name}; cannot add a title`) + return first.id + } + /** * Lowercased torrent infohashes (`downloadId`s) that this *arr has finished * importing recently, read from its history. The import watcher matches these diff --git a/apps/backend/src/lib/servers/arr/radarr.ts b/apps/backend/src/lib/servers/arr/radarr.ts index dd9de37..63876df 100644 --- a/apps/backend/src/lib/servers/arr/radarr.ts +++ b/apps/backend/src/lib/servers/arr/radarr.ts @@ -1,11 +1,23 @@ import type { MovieFileResource, MovieResource } from '@jack/schemas/radarr/types' import type { AutoRegisterConfig, ConnectorHeadersConfig } from '../../config' import type { Release } from '../../release' +import type { AddParams, ManualImportParams } from './base' +import { z } from 'zod' +import { BadRequestError } from '../../errors/BadRequestError' import { normalizeImdbId, ReleaseCategory } from '../../release' import { setSpanAttribute, setSpanAttributes } from '../../span-attributes' import { withSpan } from '../../tracing' import { ArrServerConnector, basename, stripExtension } from './base' +const CreatedId = z.object({ id: z.number().int() }) + +interface ManualImportCandidate { + path?: string + quality?: unknown + languages?: unknown[] + releaseGroup?: string +} + export class RadarrServerConnector extends ArrServerConnector { constructor(config: { url: string, apiKey: string, name: string, source: boolean, destination: boolean, autoregister: AutoRegisterConfig, headers?: ConnectorHeadersConfig }) { super({ @@ -24,6 +36,10 @@ export class RadarrServerConnector extends ArrServerConnector { return 'movieCategory' } + protected override get internalIndexerFlagValue(): number { + return 32 + } + private toRelease(movie: MovieResource): Release | null { const file = movie.movieFile if (!movie.id || !movie.hasFile || !file) @@ -146,4 +162,69 @@ export class RadarrServerConnector extends ArrServerConnector { const movie = await this.getMovie(id) return (movie?.movieFile as MovieFileResource | undefined)?.path ?? null } + + protected override async doAdd(params: AddParams): Promise { + if (params.tmdbId == null) + throw new BadRequestError('A tmdbId is required to add a movie to Radarr') + + // Idempotent: reuse the movie if it's already in the library. + const existing = await this.arrGet('/api/v3/movie', { tmdbId: String(params.tmdbId) }) + const found = Array.isArray(existing) ? existing.find(m => m.id != null) : undefined + if (found?.id != null) + return found.id + + const lookup = await this.arrGet('/api/v3/movie/lookup', { term: `tmdb:${params.tmdbId}` }) + const movie = Array.isArray(lookup) ? lookup[0] : undefined + if (!movie) + throw new BadRequestError(`No movie found on ${this.name} for tmdbId ${params.tmdbId}`) + + const body = { + ...movie, + qualityProfileId: await this.resolveQualityProfileId(), + rootFolderPath: params.rootFolderPath, + monitored: true, + minimumAvailability: 'released', + addOptions: { searchForMovie: false }, + } + const created = await this.fetch('/api/v3/movie', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + schema: CreatedId, + }) + return created.id + } + + protected override async doManualImport(params: ManualImportParams): Promise { + if (params.target.kind !== 'movie') + throw new BadRequestError(`Radarr cannot import a "${params.target.kind}" target`) + const { movieId } = params.target + + const candidates = await this.arrGet('/api/v3/manualimport', { + folder: params.folder, + movieId: String(movieId), + filterExistingFiles: 'false', + }) + const wanted = new Set(params.paths) + const files = (Array.isArray(candidates) ? candidates : []) + .filter((c): c is ManualImportCandidate & { path: string } => typeof c.path === 'string' && wanted.has(c.path)) + .map(c => ({ + path: c.path, + movieId, + quality: c.quality, + languages: c.languages ?? [], + releaseGroup: c.releaseGroup ?? '', + downloadId: params.downloadId, + })) + if (files.length === 0) + throw new BadRequestError(`Radarr found no importable file for movie ${movieId} in ${params.folder}`) + + const command = await this.fetch('/api/v3/command', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'ManualImport', importMode: 'move', files }), + schema: CreatedId, + }) + return command.id + } } diff --git a/apps/backend/src/lib/servers/arr/sonarr.ts b/apps/backend/src/lib/servers/arr/sonarr.ts index 22f5018..8da820f 100644 --- a/apps/backend/src/lib/servers/arr/sonarr.ts +++ b/apps/backend/src/lib/servers/arr/sonarr.ts @@ -1,13 +1,26 @@ import type { EpisodeFileResource, EpisodeResource, SeriesResource } from '@jack/schemas/sonarr/types' import type { AutoRegisterConfig, ConnectorHeadersConfig } from '../../config' import type { Release } from '../../release' +import type { AddParams, ManualImportParams } from './base' +import { z } from 'zod' +import { BadRequestError } from '../../errors/BadRequestError' import { ReleaseCategory } from '../../release' import { setSpanAttributes } from '../../span-attributes' import { withSpan } from '../../tracing' -import { ArrServerConnector, basename, stripExtension } from './base' +import { ArrServerConnector, basename, PermanentManualImportError, stripExtension } from './base' type SeriesWithId = SeriesResource & { id: number } +const CreatedId = z.object({ id: z.number().int() }) + +interface SonarrManualImportCandidate { + path?: string + quality?: unknown + languages?: unknown[] + releaseGroup?: string + episodes?: Array<{ id?: number }> +} + export class SonarrServerConnector extends ArrServerConnector { constructor(config: { url: string, apiKey: string, name: string, source: boolean, destination: boolean, autoregister: AutoRegisterConfig, headers?: ConnectorHeadersConfig }) { super({ @@ -26,6 +39,10 @@ export class SonarrServerConnector extends ArrServerConnector { return 'tvCategory' } + protected override get internalIndexerFlagValue(): number { + return 8 + } + private buildRelease(episode: EpisodeResource, series: SeriesResource | undefined, file: EpisodeFileResource | undefined): Release | null { if (!episode.id || !episode.hasFile || !file) return null @@ -73,6 +90,16 @@ export class SonarrServerConnector extends ArrServerConnector { return Array.isArray(episodes) ? episodes : [] } + private async episodeIdsFromRelease(seriesId: number, release: ManualImportParams['release']): Promise { + if (release?.season == null || release.episode == null) + return [] + const episodes = await this.listEpisodes(seriesId) + return episodes + .filter(e => e.seasonNumber === release.season && e.episodeNumber === release.episode) + .map(e => e.id) + .filter((id): id is number => id != null) + } + private async releasesForSeries(series: SeriesWithId, filter?: (e: EpisodeResource) => boolean): Promise { const episodes = await this.listEpisodes(series.id) return episodes @@ -163,4 +190,76 @@ export class SonarrServerConnector extends ArrServerConnector { const bundle = await this.fetchEpisodeBundle(id) return bundle?.file?.path ?? null } + + protected override async doAdd(params: AddParams): Promise { + if (params.tvdbId == null) + throw new BadRequestError('A tvdbId is required to add a series to Sonarr') + + const existing = await this.listSeries({ tvdbId: String(params.tvdbId) }) + if (existing[0]?.id != null) + return existing[0].id + + const lookup = await this.arrGet('/api/v3/series/lookup', { term: `tvdb:${params.tvdbId}` }) + const series = Array.isArray(lookup) ? lookup[0] : undefined + if (!series) + throw new BadRequestError(`No series found on ${this.name} for tvdbId ${params.tvdbId}`) + + const body = { + ...series, + qualityProfileId: await this.resolveQualityProfileId(), + rootFolderPath: params.rootFolderPath, + monitored: true, + seasonFolder: true, + addOptions: { monitor: 'all', searchForMissingEpisodes: false }, + } + const created = await this.fetch('/api/v3/series', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + schema: CreatedId, + }) + return created.id + } + + protected override async doManualImport(params: ManualImportParams): Promise { + if (params.target.kind !== 'series') + throw new BadRequestError(`Sonarr cannot import a "${params.target.kind}" target`) + const { seriesId } = params.target + + const candidates = await this.arrGet('/api/v3/manualimport', { + folder: params.folder, + seriesId: String(seriesId), + filterExistingFiles: 'false', + }) + const wanted = new Set(params.paths) + const matches = (Array.isArray(candidates) ? candidates : []) + .filter((c): c is SonarrManualImportCandidate & { path: string } => typeof c.path === 'string' && wanted.has(c.path)) + if (matches.length === 0) + throw new BadRequestError(`Sonarr found no importable episode file for series ${seriesId} in ${params.folder}`) + + const fallbackEpisodeIds = await this.episodeIdsFromRelease(seriesId, params.release) + const files = matches.map((c) => { + const parsedEpisodeIds = (c.episodes ?? []).map(e => e.id).filter((id): id is number => id != null) + const episodeIds = parsedEpisodeIds.length > 0 ? parsedEpisodeIds : fallbackEpisodeIds + if (episodeIds.length === 0) + throw new PermanentManualImportError(`Sonarr could not resolve episode ids for ${c.path}`) + return { + path: c.path, + seriesId, + episodeIds, + quality: c.quality, + languages: c.languages ?? [], + releaseGroup: c.releaseGroup ?? '', + downloadId: params.downloadId, + } + }) + + const command = await this.fetch('/api/v3/command', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'ManualImport', importMode: 'move', files }), + schema: CreatedId, + }) + return command.id + } } diff --git a/apps/backend/src/lib/tmdb/client.ts b/apps/backend/src/lib/tmdb/client.ts new file mode 100644 index 0000000..02fbca4 --- /dev/null +++ b/apps/backend/src/lib/tmdb/client.ts @@ -0,0 +1,86 @@ +const TMDB_API_BASE = 'https://api.themoviedb.org/3' +const TMDB_IMAGE_BASE = 'https://image.tmdb.org/t/p/' + +export type TmdbMediaType = 'movie' | 'tv' + +export interface TmdbMetadata { + tmdbId: number + title: string + overview: string | null + year: number | null + rating: number | null + posterUrl: string | null + backdropUrl: string | null + genres: string[] +} + +interface TmdbRawDetail { + id: number + title?: string + name?: string + overview?: string | null + release_date?: string | null + first_air_date?: string | null + vote_average?: number | null + poster_path?: string | null + backdrop_path?: string | null + genres?: Array<{ id: number, name: string }> +} + +/** Assemble a TMDB image URL, or null when the path is absent. */ +export function buildImageUrl(path: string | null | undefined, size = 'w500', base = TMDB_IMAGE_BASE): string | null { + if (!path) + return null + return `${base}${size}${path}` +} + +/** Normalize a TMDB movie/tv detail payload into our flat metadata shape. */ +export function mapTmdbDetail(raw: TmdbRawDetail): TmdbMetadata { + const date = raw.release_date ?? raw.first_air_date ?? null + const yearNum = date && date.length >= 4 ? Number(date.slice(0, 4)) : Number.NaN + return { + tmdbId: raw.id, + title: raw.title ?? raw.name ?? 'Untitled', + overview: raw.overview || null, + year: Number.isFinite(yearNum) ? yearNum : null, + rating: typeof raw.vote_average === 'number' ? raw.vote_average : null, + posterUrl: buildImageUrl(raw.poster_path), + backdropUrl: buildImageUrl(raw.backdrop_path, 'w780'), + genres: (raw.genres ?? []).map(g => g.name).filter((n): n is string => Boolean(n)), + } +} + +/** + * Thin TMDB v3 read client. Holds a per-process cache keyed by media/id so a + * catalog full of repeated titles enriches each unique id once. + */ +export class TmdbClient { + private readonly cache = new Map() + constructor(private readonly apiKey: string) {} + + /** True when the key authenticates against TMDB (`/configuration` returns 200). */ + async ping(): Promise { + const res = await fetch(`${TMDB_API_BASE}/configuration?api_key=${this.apiKey}`) + return res.ok + } + + /** Normalized metadata for a tmdb id, cached per process; null on 404. */ + async getMetadata(mediaType: TmdbMediaType, tmdbId: number): Promise { + const key = `${mediaType}:${tmdbId}` + const cached = this.cache.get(key) + if (cached !== undefined) + return cached + + const res = await fetch(`${TMDB_API_BASE}/${mediaType}/${tmdbId}?api_key=${this.apiKey}`) + if (res.status === 404) { + this.cache.set(key, null) + return null + } + if (!res.ok) + throw new Error(`TMDB ${mediaType}/${tmdbId} failed: ${res.status}`) + const raw = await res.json() as TmdbRawDetail + const meta = mapTmdbDetail(raw) + this.cache.set(key, meta) + return meta + } +} diff --git a/apps/backend/src/management-app.ts b/apps/backend/src/management-app.ts index 2c40b34..eafcb1d 100644 --- a/apps/backend/src/management-app.ts +++ b/apps/backend/src/management-app.ts @@ -2,12 +2,16 @@ import type { ConnectorManager } from './lib/servers' import type { ApiKeysRepository } from './modules/api-keys/api-keys.repository' import type { ConfigService } from './modules/config/config.service' import type { DownloadsRepository } from './modules/downloads/downloads.repository' +import type { DownloadsService } from './modules/downloads/downloads.service' import { Hono } from 'hono' import { secureHeaders } from 'hono/secure-headers' +import { TmdbClient } from './lib/tmdb/client' import { handleError } from './middleware/handle-error' import { requireManagementKey } from './middleware/require-management-key' import { ApiKeysController } from './modules/api-keys/api-keys.controller' import { getApiKeysRouter } from './modules/api-keys/api-keys.router' +import { CatalogController } from './modules/catalog/catalog.controller' +import { getCatalogRouter } from './modules/catalog/catalog.router' import { ConfigController } from './modules/config/config.controller' import { getConfigRouter } from './modules/config/config.router' import { StatusController } from './modules/status/status.controller' @@ -20,7 +24,9 @@ export function getManagementApp(params: { connectors: { servers: ConnectorManager['servers'], peers: ConnectorManager['peers'] } configService?: ConfigService downloadsRepository?: DownloadsRepository + downloadsService?: DownloadsService apiKeysRepository?: ApiKeysRepository + tmdbApiKey?: string }) { const app = new Hono() @@ -38,6 +44,10 @@ export function getManagementApp(params: { const statusController = new StatusController(params.connectors, params.downloadsRepository) app.route('/', getStatusRouter(statusController)) + const tmdbClient = params.tmdbApiKey ? new TmdbClient(params.tmdbApiKey) : undefined + const catalogController = new CatalogController(params.connectors, tmdbClient, params.downloadsService) + app.route('/catalog', getCatalogRouter(catalogController)) + if (params.apiKeysRepository) { const apiKeysController = new ApiKeysController(params.apiKeysRepository) app.route('/api-keys', getApiKeysRouter(apiKeysController)) diff --git a/apps/backend/src/modules/catalog/catalog.controller.ts b/apps/backend/src/modules/catalog/catalog.controller.ts new file mode 100644 index 0000000..be0163c --- /dev/null +++ b/apps/backend/src/modules/catalog/catalog.controller.ts @@ -0,0 +1,182 @@ +import type { ArrServerConnector } from '../../lib/servers/arr/base' +import type { PeerConnector } from '../../lib/servers/peer' +import type { TmdbClient, TmdbMediaType, TmdbMetadata } from '../../lib/tmdb/client' +import type { DownloadsService } from '../downloads/downloads.service' +import type { PeerReleases, UnifiedCatalogTitle } from './catalog.lib' +import { BadRequestError } from '../../lib/errors/BadRequestError' +import { NotFoundError } from '../../lib/errors/NotFoundError' +import { groupReleasesIntoUnifiedTitles, pickBestPerEpisode, pickBestRelease } from './catalog.lib' + +export interface CatalogResponse { + // Peers that responded and contributed to this catalog. + peers: Array<{ id: string, name: string }> + titles: UnifiedCatalogTitle[] +} + +export interface TmdbStatus { + configured: boolean + ok: boolean + error?: string +} + +export interface RequestServerOption { + id: string + name: string + type: 'radarr' | 'sonarr' + mediaType: 'movie' | 'tv' + rootFolders: Array<{ path: string, freeSpace?: number }> +} + +export interface CatalogRequestInput { + peerId: string + serverId: string + mediaType: 'movie' | 'tv' + tmdbId?: number + tvdbId?: number + rootFolderPath: string +} + +export class CatalogController { + constructor( + private readonly connectors: { servers: ArrServerConnector[], peers: PeerConnector[] }, + private readonly tmdb?: TmdbClient, + private readonly downloads?: DownloadsService, + ) {} + + private requirePeer(peerId: string): PeerConnector { + const peer = this.connectors.peers.find(p => p.id === peerId) + if (!peer) + throw new NotFoundError(`No peer found with id "${peerId}"`) + return peer + } + + async getCatalog(): Promise { + // Fan out to every initialized peer. A peer that can't serve its catalog is + // skipped (partial results) rather than failing the whole aggregate. + const peers = this.connectors.peers.filter(p => p.isInitialized) + const results = await Promise.all(peers.map(async (peer): Promise => { + try { + const releases = await peer.listReleases() + return { peer: { id: peer.id, name: peer.name }, releases } + } + catch { + return null + } + })) + const responded = results.filter((r): r is PeerReleases => r !== null) + return { + peers: responded.map(r => r.peer), + titles: groupReleasesIntoUnifiedTitles(responded), + } + } + + /** TMDB metadata for a single title; null when TMDB is unconfigured or the id is unknown. */ + async getTitleMetadata(mediaType: TmdbMediaType, tmdbId: number): Promise { + if (!this.tmdb) + return null + return this.tmdb.getMetadata(mediaType, tmdbId) + } + + async getRequestOptions(): Promise { + const destinations = this.connectors.servers.filter(s => s.canDestination && s.isInitialized) + const options = await Promise.all(destinations.map(async (s) => { + try { + const rootFolders = await s.getRootFolders() + const type = s.type as 'radarr' | 'sonarr' + return { + id: s.id, + name: s.name, + type, + mediaType: type === 'sonarr' ? 'tv' : 'movie', + rootFolders, + } satisfies RequestServerOption + } + catch { + // A destination that can't list its root folders can't take a request — drop it. + return null + } + })) + return options.filter((o): o is RequestServerOption => o !== null) + } + + async requestDownload(input: CatalogRequestInput): Promise<{ ok: true, server: string, started: number }> { + if (!this.downloads) + throw new BadRequestError('Downloads are not configured on this Jack instance') + + const server = this.connectors.servers.find(s => s.id === input.serverId) + if (!server) + throw new NotFoundError(`No server found with id "${input.serverId}"`) + if (!server.canDestination) + throw new BadRequestError(`Server "${server.name}" is not a destination`) + // Defense in depth (the UI already filters): a movie must go to Radarr, tv to Sonarr. + const expectedType = input.mediaType === 'tv' ? 'sonarr' : 'radarr' + if (server.type !== expectedType) + throw new BadRequestError(`Server "${server.name}" cannot handle ${input.mediaType} requests`) + + const peer = this.requirePeer(input.peerId) + + if (input.mediaType === 'movie') { + if (input.tmdbId == null) + throw new BadRequestError('A tmdbId is required for a movie request') + const releases = await peer.searchByTmdbId(String(input.tmdbId)) + const best = pickBestRelease(releases) + if (!best) + throw new NotFoundError(`Peer "${peer.name}" has no release for tmdbId ${input.tmdbId}`) + + const movieId = await server.add({ tmdbId: input.tmdbId, rootFolderPath: input.rootFolderPath }) + const result = await this.downloads.startDirectDownload({ + peerId: peer.id, + itemId: best.id, + destinationServerName: server.name, + destinationServerId: server.id, + importTarget: { kind: 'movie', movieId }, + }) + if (result === 'failed') + throw new BadRequestError(`Failed to start the download for tmdbId ${input.tmdbId} from peer "${peer.name}"`) + if (result === 'duplicate') + return { ok: true, server: server.name, started: 0 } + return { ok: true, server: server.name, started: 1 } + } + + // --- series (tv): one direct download per best-per-episode release, all bound + // to the same series so the watcher imports each file into the right show. --- + if (input.tvdbId == null) + throw new BadRequestError('A tvdbId is required for a series request') + const episodeReleases = await peer.searchByTvdbId(String(input.tvdbId)) + const best = pickBestPerEpisode(episodeReleases) + if (best.length === 0) + throw new NotFoundError(`Peer "${peer.name}" has no episodes for tvdbId ${input.tvdbId}`) + + const seriesId = await server.add({ tvdbId: input.tvdbId, rootFolderPath: input.rootFolderPath }) + let started = 0 + let accepted = 0 + for (const release of best) { + const result = await this.downloads.startDirectDownload({ + peerId: peer.id, + itemId: release.id, + destinationServerId: server.id, + destinationServerName: server.name, + importTarget: { kind: 'series', seriesId }, + }) + if (result === 'failed') + continue + accepted++ + if (result === 'started') + started++ + } + if (accepted === 0) + throw new BadRequestError(`Failed to start any episode download for tvdbId ${input.tvdbId} from peer "${peer.name}"`) + return { ok: true, server: server.name, started } + } + + async getTmdbStatus(): Promise { + if (!this.tmdb) + return { configured: false, ok: false } + try { + return { configured: true, ok: await this.tmdb.ping() } + } + catch (err) { + return { configured: true, ok: false, error: err instanceof Error ? err.message : String(err) } + } + } +} diff --git a/apps/backend/src/modules/catalog/catalog.lib.ts b/apps/backend/src/modules/catalog/catalog.lib.ts new file mode 100644 index 0000000..e00c0c4 --- /dev/null +++ b/apps/backend/src/modules/catalog/catalog.lib.ts @@ -0,0 +1,170 @@ +import type { Release } from '../../lib/release' +import type { TmdbMetadata } from '../../lib/tmdb/client' +import { ReleaseCategory } from '../../lib/release' + +/** Per-release detail kept for a title within a single peer's bucket. */ +export type CatalogRelease = Pick + +/** One peer's contribution to a unified title. */ +export interface CatalogTitlePeer { + id: string + name: string + releaseCount: number + totalSize: number + releases: CatalogRelease[] +} + +/** A title unified across every peer that carries it. */ +export interface UnifiedCatalogTitle { + key: string + mediaType: 'movie' | 'tv' + tmdbId?: number + imdbId?: string + tvdbId?: number + displayTitle: string + // Totals across every peer that carries this title. + releaseCount: number + totalSize: number + metadata?: TmdbMetadata | null + peers: CatalogTitlePeer[] +} + +/** A peer's flat release list, tagged with the peer it came from. */ +export interface PeerReleases { + peer: { id: string, name: string } + releases: Release[] +} + +function mediaTypeOf(release: Release): 'movie' | 'tv' { + return release.category === ReleaseCategory.Tv ? 'tv' : 'movie' +} + +/** The strong (id-based) grouping key for a release, or null when it carries no id. */ +function strongKey(release: Release): string | null { + const mediaType = mediaTypeOf(release) + const id = mediaType === 'tv' + ? (release.tvdbId ?? release.tmdbId) + : (release.tmdbId ?? release.imdbId) + return id == null ? null : `${mediaType}:id:${id}` +} + +/** The fallback (name-based) key for a release with no usable id. */ +function nameKey(release: Release): string { + const mediaType = mediaTypeOf(release) + const name = (mediaType === 'tv' ? (release.seriesTitle ?? release.title) : release.title).toLowerCase() + return `${mediaType}:name:${name}` +} + +/** + * Fold every peer's releases into one title per movie/series. + * + * Reuses the single-peer grouping keys (strong id key, else the name->id alias from + * pass 1, else the name key) but builds the alias map across ALL peers' releases so a + * title that is id-less on one peer and id-bearing on another still collapses. Each + * title tracks a per-peer bucket with that peer's release detail. + */ +export function groupReleasesIntoUnifiedTitles(peerReleases: PeerReleases[]): UnifiedCatalogTitle[] { + const strongKeysByName = new Map>() + for (const { releases } of peerReleases) { + for (const release of releases) { + const sk = strongKey(release) + if (!sk) + continue + const nk = nameKey(release) + const keys = strongKeysByName.get(nk) ?? new Set() + keys.add(sk) + strongKeysByName.set(nk, keys) + } + } + + const nameToStrongKey = new Map() + for (const [nk, keys] of strongKeysByName) { + if (keys.size !== 1) + continue + const key = keys.values().next().value + if (typeof key === 'string') + nameToStrongKey.set(nk, key) + } + + const keyOf = (release: Release): string => + strongKey(release) ?? nameToStrongKey.get(nameKey(release)) ?? nameKey(release) + + const byKey = new Map() + for (const { peer, releases } of peerReleases) { + for (const release of releases) { + const key = keyOf(release) + + let title = byKey.get(key) + if (!title) { + title = { + key, + mediaType: mediaTypeOf(release), + tmdbId: release.tmdbId, + imdbId: release.imdbId, + tvdbId: release.tvdbId, + displayTitle: mediaTypeOf(release) === 'tv' ? (release.seriesTitle ?? release.title) : release.title, + releaseCount: 0, + totalSize: 0, + peers: [], + } + byKey.set(key, title) + } + + // Backfill ids if a later release carries one the first lacked. + title.tmdbId ??= release.tmdbId + title.imdbId ??= release.imdbId + title.tvdbId ??= release.tvdbId + title.releaseCount += 1 + title.totalSize += release.size + + let bucket = title.peers.find(p => p.id === peer.id) + if (!bucket) { + bucket = { id: peer.id, name: peer.name, releaseCount: 0, totalSize: 0, releases: [] } + title.peers.push(bucket) + } + bucket.releaseCount += 1 + bucket.totalSize += release.size + bucket.releases.push({ + id: release.id, + title: release.title, + filename: release.filename, + size: release.size, + quality: release.quality, + season: release.season, + episode: release.episode, + }) + } + } + + return [...byKey.values()].sort((a, b) => a.displayTitle.localeCompare(b.displayTitle)) +} + +/** + * Higher resolution wins; ties break to the larger file; full ties break to the + * lexicographically lowest release id so the pick is deterministic regardless of + * the order the peer returns its catalog in. + */ +function isBetterRelease(candidate: Release, current: Release): boolean { + const c = candidate.quality?.resolution ?? 0 + const r = current.quality?.resolution ?? 0 + if (c !== r) + return c > r + if (candidate.size !== current.size) + return candidate.size > current.size + return candidate.id.localeCompare(current.id) < 0 +} + +export function pickBestRelease(releases: Release[]): Release | undefined { + return releases.reduce((best, r) => (!best || isBetterRelease(r, best)) ? r : best, undefined) +} + +export function pickBestPerEpisode(releases: Release[]): Release[] { + const byEpisode = new Map() + for (const r of releases) { + const key = r.season != null && r.episode != null ? `${r.season}:${r.episode}` : `unparsed:${r.id}` + const current = byEpisode.get(key) + if (!current || isBetterRelease(r, current)) + byEpisode.set(key, r) + } + return [...byEpisode.values()] +} diff --git a/apps/backend/src/modules/catalog/catalog.router.ts b/apps/backend/src/modules/catalog/catalog.router.ts new file mode 100644 index 0000000..1df3011 --- /dev/null +++ b/apps/backend/src/modules/catalog/catalog.router.ts @@ -0,0 +1,42 @@ +import type { CatalogController } from './catalog.controller' +import { Hono } from 'hono' +import { validator as zValidator } from 'hono-openapi' +import { z } from 'zod' + +const tmdbParam = z.object({ + mediaType: z.enum(['movie', 'tv']), + tmdbId: z.coerce.number().int(), +}) + +const requestBody = z.object({ + peerId: z.string().min(1), + serverId: z.string().min(1), + mediaType: z.enum(['movie', 'tv']), + tmdbId: z.number().int().optional(), + tvdbId: z.number().int().optional(), + rootFolderPath: z.string().min(1), +}) + +export function getCatalogRouter(controller: CatalogController) { + const app = new Hono() + + // Register the static path before any future dynamic segment. + app.get('/tmdb/status', async c => c.json(await controller.getTmdbStatus())) + + // Per-title TMDB lookup the catalog grid calls once per visible card. + app.get('/tmdb/:mediaType/:tmdbId', zValidator('param', tmdbParam), async (c) => { + const { mediaType, tmdbId } = c.req.valid('param') + return c.json(await controller.getTitleMetadata(mediaType, tmdbId)) + }) + + app.get('/request-options', async c => c.json({ servers: await controller.getRequestOptions() })) + + app.post('/request', zValidator('json', requestBody), async (c) => { + return c.json(await controller.requestDownload(c.req.valid('json'))) + }) + + // Aggregated catalog across all initialized peers. + app.get('/', async c => c.json(await controller.getCatalog())) + + return app +} diff --git a/apps/backend/src/modules/downloads/downloads.repository.ts b/apps/backend/src/modules/downloads/downloads.repository.ts index 743c86a..d976d14 100644 --- a/apps/backend/src/modules/downloads/downloads.repository.ts +++ b/apps/backend/src/modules/downloads/downloads.repository.ts @@ -1,6 +1,7 @@ import type { AppDatabase } from '../../database/connection' import type { DownloadRow, DownloadStatus, ExpectedBytesSource, NewDownloadRow } from '../../database/schema' import type { Release } from '../../lib/release' +import type { ManualImportTarget } from '../../lib/servers/arr/base' import { desc, eq, sql } from 'drizzle-orm' import { downloads } from '../../database/schema' @@ -27,6 +28,10 @@ export interface DownloadRecord { error: string | null qbCategory: string | null qbSourceServer: string | null + sourceServerId: string | null + importMode: 'jack_manual' | null + importTarget: ManualImportTarget | null + manualImportCommandId: number | null } export interface CreateDownloadInput { @@ -41,6 +46,10 @@ export interface CreateDownloadInput { release: Release qbCategory?: string | null qbSourceServer?: string | null + sourceServerId?: string | null + importMode?: 'jack_manual' | null + importTarget?: ManualImportTarget | null + manualImportCommandId?: number | null } function nowIso() { @@ -71,6 +80,10 @@ function toRecord(row: DownloadRow): DownloadRecord { error: row.error, qbCategory: row.qbCategory ?? null, qbSourceServer: row.qbSourceServer ?? null, + sourceServerId: row.sourceServerId ?? null, + importMode: row.importMode ?? null, + importTarget: row.importTarget ? (JSON.parse(row.importTarget) as ManualImportTarget) : null, + manualImportCommandId: row.manualImportCommandId ?? null, } } @@ -91,6 +104,10 @@ export class DownloadsRepository { releaseJson: JSON.stringify(input.release), qbCategory: input.qbCategory ?? null, qbSourceServer: input.qbSourceServer ?? null, + sourceServerId: input.sourceServerId ?? null, + importMode: input.importMode ?? null, + importTarget: input.importTarget ? JSON.stringify(input.importTarget) : null, + manualImportCommandId: input.manualImportCommandId ?? null, downloadedBytes: 0, status: 'downloading', startedAt: timestamp, @@ -152,6 +169,13 @@ export class DownloadsRepository { .run() } + setManualImportCommand(id: number, commandId: number): void { + this.db.update(downloads) + .set({ manualImportCommandId: commandId, updatedAt: nowIso() }) + .where(eq(downloads.id, id)) + .run() + } + markFailed(id: number, error: string): void { this.db.update(downloads) .set({ status: 'failed', error, updatedAt: nowIso() }) diff --git a/apps/backend/src/modules/downloads/downloads.service.ts b/apps/backend/src/modules/downloads/downloads.service.ts index 0e6839c..3f1dd0f 100644 --- a/apps/backend/src/modules/downloads/downloads.service.ts +++ b/apps/backend/src/modules/downloads/downloads.service.ts @@ -1,5 +1,6 @@ import type { AppConfig } from '../../lib/config' import type { ConnectorManager } from '../../lib/servers' +import type { ManualImportTarget } from '../../lib/servers/arr/base' import type { PeerDownloadProgressEvent } from '../../lib/servers/peer' import type { DownloadRecord, DownloadsRepository } from './downloads.repository' import { basename, join } from 'node:path' @@ -59,6 +60,9 @@ export class DownloadsService { torrentFilename: string qbCategory?: string | null qbSourceServer?: string | null + sourceServerId?: string | null + importMode?: 'jack_manual' | null + importTarget?: ManualImportTarget | null }): Promise { const { peerId, itemId, torrentFilename } = input const peer = this.peers.find(p => p.id === peerId) @@ -99,6 +103,9 @@ export class DownloadsService { release, qbCategory: input.qbCategory ?? null, qbSourceServer: input.qbSourceServer ?? null, + sourceServerId: input.sourceServerId ?? null, + importMode: input.importMode ?? null, + importTarget: input.importTarget ?? null, }) return { @@ -126,6 +133,10 @@ export class DownloadsService { error: null, qbCategory: input.qbCategory ?? null, qbSourceServer: input.qbSourceServer ?? null, + sourceServerId: input.sourceServerId ?? null, + importMode: input.importMode ?? null, + importTarget: input.importTarget ?? null, + manualImportCommandId: null, }, } } @@ -139,6 +150,7 @@ export class DownloadsService { itemId: string qbCategory: string qbSourceServer: string + sourceServerId: string }): Promise { // qB-added downloads have no on-disk stub, but createDownload + the row still // need a stable filename. @@ -164,6 +176,47 @@ export class DownloadsService { return 'started' } + /** + * Catalog direct-download entrypoint: create a jack_manual row bound to a + * destination *arr + import target, then drive the download in the background. + * Import is pushed later by the ImportWatcher. + */ + async startDirectDownload(input: { + peerId: string + itemId: string + destinationServerName: string + destinationServerId: string + importTarget: ManualImportTarget + }): Promise { + const torrentFilename = `direct-${input.peerId}-${input.itemId}.torrent`.replace(UNSAFE_FILENAME_CHARS, '_') + let outcome: CreateDownloadOutcome + try { + outcome = await this.createDownload({ + peerId: input.peerId, + itemId: input.itemId, + torrentFilename, + qbSourceServer: input.destinationServerName, + sourceServerId: input.destinationServerId, + importMode: 'jack_manual', + importTarget: input.importTarget, + }) + } + catch (err) { + const message = err instanceof Error ? err.message : String(err) + logger.error({ peerId: input.peerId, itemId: input.itemId, error: message }, 'Failed to create direct download') + return 'failed' + } + if (outcome.kind === 'no-peer') + return 'failed' + if (outcome.kind === 'duplicate') + return 'duplicate' + void this.runDownload(outcome.record).catch((err) => { + const message = err instanceof Error ? err.message : String(err) + logger.error({ itemId: input.itemId, error: message }, 'Direct download failed') + }) + return 'started' + } + /** Re-drive stale `downloading` rows from a prior run, resuming from their .part files. */ async resumeStaleDownloads(): Promise { const repo = this.downloadsRepository diff --git a/apps/backend/src/modules/downloads/import-watcher.ts b/apps/backend/src/modules/downloads/import-watcher.ts index bec7214..966ec2b 100644 --- a/apps/backend/src/modules/downloads/import-watcher.ts +++ b/apps/backend/src/modules/downloads/import-watcher.ts @@ -1,5 +1,7 @@ import type { ArrServerConnector } from '../../lib/servers/arr/base' -import type { DownloadsRepository } from './downloads.repository' +import type { DownloadRecord, DownloadsRepository } from './downloads.repository' +import { dirname } from 'node:path' +import { PermanentManualImportError } from '../../lib/servers/arr/base' import { logger } from '../../logger' import { deriveHash } from '../qbittorrent/qbittorrent.mapper' @@ -22,6 +24,15 @@ export class ImportWatcher { private readonly intervalMs: number, ) {} + private connectorFor(row: DownloadRecord): ArrServerConnector | undefined { + if (row.sourceServerId) { + const byId = this.connectorManager.servers.find(s => s.id === row.sourceServerId) + if (byId) + return byId + } + return row.qbSourceServer ? this.connectorManager.servers.find(s => s.name === row.qbSourceServer) : undefined + } + start(): void { if (this.timer) return @@ -44,42 +55,85 @@ export class ImportWatcher { /** One reconciliation pass. Returns how many downloads it marked imported. */ async tick(): Promise { - // Only qB-added downloads (qbSourceServer set) are imported by an *arr we can - // poll; blackhole rows have no server to ask, so they're left as-is. - const queued = this.repository.listByStatus('import_queued').filter(r => r.qbSourceServer) + // Only rows with an owning destination can be reconciled; blackhole rows have + // no server to poll, so they're left as-is. + const queued = this.repository.listByStatus('import_queued').filter(r => r.sourceServerId || r.qbSourceServer) if (queued.length === 0) return 0 - const byServer = new Map() - for (const row of queued) { - const group = byServer.get(row.qbSourceServer!) ?? [] - group.push(row) - byServer.set(row.qbSourceServer!, group) - } - + const importedIdsByConnector = new Map>() + const skippedConnectors = new Set() let importedCount = 0 - for (const [serverName, rows] of byServer) { - const connector = this.connectorManager.servers.find(s => s.name === serverName) + + for (const row of queued) { + const connector = this.connectorFor(row) if (!connector || !connector.isInitialized) continue - let importedIds: Set - try { - importedIds = await connector.recentlyImportedDownloadIds() + if (!importedIdsByConnector.has(connector.id) && !skippedConnectors.has(connector.id)) { + try { + importedIdsByConnector.set(connector.id, await connector.recentlyImportedDownloadIds()) + } + catch (err) { + const message = err instanceof Error ? err.message : String(err) + skippedConnectors.add(connector.id) + logger.warn({ server: connector.name, error: message }, 'Could not read import history; will retry next tick') + } } - catch (err) { - const message = err instanceof Error ? err.message : String(err) - logger.warn({ server: serverName, error: message }, 'Could not read import history; will retry next tick') + const importedIds = importedIdsByConnector.get(connector.id) + if (!importedIds) continue - } - for (const row of rows) { - const hash = deriveHash(row.release.title, row.releaseSize).toLowerCase() - if (!importedIds.has(hash)) - continue + const hash = deriveHash(row.release.title, row.releaseSize).toLowerCase() + if (importedIds.has(hash)) { this.repository.markImported(row.id) importedCount++ - logger.info({ id: row.id, filename: row.filename, server: serverName }, 'Download imported by *arr') + logger.info({ id: row.id, filename: row.filename, server: connector.name }, 'Download imported by *arr') + continue + } + + if (row.importMode !== 'jack_manual' || !row.importTarget) + continue + + if (row.manualImportCommandId != null) { + try { + const status = await connector.manualImportCommandStatus(row.manualImportCommandId) + if (status.state === 'completed') { + this.repository.markImported(row.id) + importedCount++ + logger.info({ id: row.id, filename: row.filename, server: connector.name, commandId: row.manualImportCommandId }, 'Manual import command completed') + } + else if (status.state === 'failed') { + this.repository.markFailed(row.id, status.error) + logger.warn({ id: row.id, server: connector.name, commandId: row.manualImportCommandId, error: status.error }, 'Manual import command failed') + } + } + catch (err) { + const message = err instanceof Error ? err.message : String(err) + logger.warn({ id: row.id, server: connector.name, commandId: row.manualImportCommandId, error: message }, 'Could not read manual import command status; will retry next tick') + } + continue + } + + try { + const commandId = await connector.manualImport({ + folder: dirname(row.destPath), + paths: [row.destPath], + target: row.importTarget, + downloadId: deriveHash(row.release.title, row.releaseSize), + release: row.release, + }) + this.repository.setManualImportCommand(row.id, commandId) + logger.info({ id: row.id, filename: row.filename, server: connector.name, commandId }, 'Triggered *arr manual import') + } + catch (err) { + const message = err instanceof Error ? err.message : String(err) + if (err instanceof PermanentManualImportError) { + this.repository.markFailed(row.id, message) + logger.warn({ id: row.id, server: connector.name, error: message }, 'Manual import cannot be retried') + continue + } + logger.warn({ id: row.id, server: connector.name, error: message }, 'Manual import trigger failed; will retry next tick') } } return importedCount diff --git a/apps/backend/src/modules/qbittorrent/qbittorrent.controller.ts b/apps/backend/src/modules/qbittorrent/qbittorrent.controller.ts index 1102776..c97e431 100644 --- a/apps/backend/src/modules/qbittorrent/qbittorrent.controller.ts +++ b/apps/backend/src/modules/qbittorrent/qbittorrent.controller.ts @@ -178,6 +178,7 @@ export class QbittorrentController { itemId: stub.itemId, qbCategory: category, qbSourceServer: input.session.serverName, + sourceServerId: input.session.serverId, }) if (result === 'failed') return 'failed' @@ -214,7 +215,7 @@ export class QbittorrentController { // shared release yields the SAME infohash across servers, an unscoped delete // could remove another server's (or a blackhole) row. async deleteTorrents(session: QbSession, hashesParam: string, deleteFiles: boolean): Promise { - const mine = (r: DownloadRecord) => r.qbSourceServer === session.serverName + const mine = (r: DownloadRecord) => r.sourceServerId ? r.sourceServerId === session.serverId : r.qbSourceServer === session.serverName const records = hashesParam === 'all' ? this.deps.repository.list().filter(mine) : hashesParam.split('|').flatMap(h => this.findAllByHash(h)).filter(mine) @@ -230,7 +231,7 @@ export class QbittorrentController { setCategory(session: QbSession, hashes: string[], category: string): void { for (const hash of hashes) { for (const record of this.findAllByHash(hash)) { - if (record.qbSourceServer === session.serverName) + if (record.sourceServerId ? record.sourceServerId === session.serverId : record.qbSourceServer === session.serverName) this.deps.repository.setQbCategory(record.id, category) } } diff --git a/apps/backend/src/modules/torznab/torznab.controller.ts b/apps/backend/src/modules/torznab/torznab.controller.ts index 6175bda..cdd0efb 100644 --- a/apps/backend/src/modules/torznab/torznab.controller.ts +++ b/apps/backend/src/modules/torznab/torznab.controller.ts @@ -54,7 +54,7 @@ export class TorznabController { private readonly jackConfig: NonNullable, ) {} - private async fanOut(label: string, search: (peer: PeerConnector) => Promise): Promise { + private async fanOut(label: string, search: (peer: PeerConnector) => Promise, apiKey: string): Promise { // We fan out to ALL peers — no isInitialized pre-filter. A peer that failed // to connect at boot gets re-initialized lazily by @requireInitialization on // the call below, so a peer that came back online rejoins searches without a @@ -80,7 +80,10 @@ export class TorznabController { }, async (peerSpan) => { const releases = await search(peer) setSpanAttribute(peerSpan, 'release.count', releases.length) - return releases.map(release => releaseToTorznab(release, peer.id, peer.name, this.jackConfig.internalUrl, this.jackConfig.apiKey ?? '')) + // Embed the SAME key the requester authenticated with (passed down + // from the request), so the grab of this release's .torrent passes + // auth — the deprecated main key (jackConfig.apiKey) is often unset. + return releases.map(release => releaseToTorznab(release, peer.id, peer.name, this.jackConfig.internalUrl, apiKey)) }) } catch (err) { @@ -96,23 +99,23 @@ export class TorznabController { }) } - async searchMovie(ids: { tmdbId?: string, imdbId?: string }): Promise { + async searchMovie(ids: { tmdbId?: string, imdbId?: string }, apiKey: string): Promise { const { tmdbId, imdbId } = ids // Prefer tmdbid: Radarr filters by it server-side (a targeted lookup), and it // doesn't depend on the tt-prefix quirk. imdbid is the fallback. if (tmdbId) - return this.fanOut(`tmdb:${tmdbId}`, peer => peer.searchByTmdbId(tmdbId)) + return this.fanOut(`tmdb:${tmdbId}`, peer => peer.searchByTmdbId(tmdbId), apiKey) if (imdbId) - return this.fanOut(`imdb:${imdbId}`, peer => peer.searchByImdbId(imdbId)) + return this.fanOut(`imdb:${imdbId}`, peer => peer.searchByImdbId(imdbId), apiKey) return [] } - async searchTv(tvdbId: string, season?: number, episode?: number): Promise { - return this.fanOut(`tvdb:${tvdbId} s:${season ?? '-'} e:${episode ?? '-'}`, peer => peer.searchByTvdbId(tvdbId, season, episode)) + async searchTv(tvdbId: string, season: number | undefined, episode: number | undefined, apiKey: string): Promise { + return this.fanOut(`tvdb:${tvdbId} s:${season ?? '-'} e:${episode ?? '-'}`, peer => peer.searchByTvdbId(tvdbId, season, episode), apiKey) } /** Full catalog of every peer's releases — backs the torznab RSS/test query. */ - async catalog(): Promise { - return this.fanOut('catalog', peer => peer.listReleases()) + async catalog(apiKey: string): Promise { + return this.fanOut('catalog', peer => peer.listReleases(), apiKey) } } diff --git a/apps/backend/src/modules/torznab/torznab.router.ts b/apps/backend/src/modules/torznab/torznab.router.ts index 979363a..fbb62b3 100644 --- a/apps/backend/src/modules/torznab/torznab.router.ts +++ b/apps/backend/src/modules/torznab/torznab.router.ts @@ -64,6 +64,10 @@ function itemToObject(item: TorznabItem): Record { // Jack files are always freely available; tell *arr not to weight ratio. { '@name': 'downloadvolumefactor', '@value': 0 }, { '@name': 'uploadvolumefactor', '@value': 1 }, + // *arr's TorznabRssParser maps `tag=internal` to the Internal indexer flag, + // marking every Jack release as coming from your own peer network so a custom + // format (IndexerFlagSpecification) can score/prefer it. + { '@name': 'tag', '@value': 'internal' }, ] if (item.imdbId) attrs.push({ '@name': 'imdbid', '@value': item.imdbId }) @@ -129,6 +133,11 @@ export function getTorznabRouter(controller: TorznabController) { return xml(c, body, 400) } + // The key the requester (Radarr/Sonarr) authenticated with — extracted the + // same way requireApiKey does. Embedded into each release's download URL so + // the subsequent grab passes auth (managed indexer keys, not the main key). + const apiKey = c.req.query('apikey') ?? c.req.header('x-api-key') ?? '' + switch (t) { case 'caps': { return xml(c, CAPS_XML) @@ -140,7 +149,7 @@ export function getTorznabRouter(controller: TorznabController) { // self-test, which *arr requires to return results). case 'search': { const q = c.req.query('q')?.trim() - const items = q ? [] : await controller.catalog() + const items = q ? [] : await controller.catalog(apiKey) const body = buildSearchResultXml(filterByCategory(items, c.req.query('cat'))) return xml(c, body) } @@ -151,9 +160,9 @@ export function getTorznabRouter(controller: TorznabController) { const q = c.req.query('q')?.trim() let items: TorznabItem[] if (tmdbId || imdbId) - items = await controller.searchMovie({ tmdbId, imdbId }) + items = await controller.searchMovie({ tmdbId, imdbId }, apiKey) else - items = q ? [] : await controller.catalog() + items = q ? [] : await controller.catalog(apiKey) const body = buildSearchResultXml(filterByCategory(items, c.req.query('cat'))) return xml(c, body) } @@ -169,10 +178,11 @@ export function getTorznabRouter(controller: TorznabController) { tvdbId, season ? Number(season) : undefined, ep ? Number(ep) : undefined, + apiKey, ) } else { - items = q ? [] : await controller.catalog() + items = q ? [] : await controller.catalog(apiKey) } const body = buildSearchResultXml(filterByCategory(items, c.req.query('cat'))) return xml(c, body) diff --git a/apps/ui/app/components/CatalogPosterCard.vue b/apps/ui/app/components/CatalogPosterCard.vue new file mode 100644 index 0000000..bfd9d17 --- /dev/null +++ b/apps/ui/app/components/CatalogPosterCard.vue @@ -0,0 +1,156 @@ + + + diff --git a/apps/ui/app/components/CatalogPosterCardSkeleton.vue b/apps/ui/app/components/CatalogPosterCardSkeleton.vue new file mode 100644 index 0000000..6b72f8c --- /dev/null +++ b/apps/ui/app/components/CatalogPosterCardSkeleton.vue @@ -0,0 +1,14 @@ + + + diff --git a/apps/ui/app/components/CatalogTitleDetail.vue b/apps/ui/app/components/CatalogTitleDetail.vue new file mode 100644 index 0000000..68b6836 --- /dev/null +++ b/apps/ui/app/components/CatalogTitleDetail.vue @@ -0,0 +1,181 @@ + + + diff --git a/apps/ui/app/components/ConnDot.vue b/apps/ui/app/components/ConnDot.vue index 94d7f19..e34bd51 100644 --- a/apps/ui/app/components/ConnDot.vue +++ b/apps/ui/app/components/ConnDot.vue @@ -2,20 +2,35 @@ const props = defineProps<{ initialized: boolean error?: string | null + // Optional identity color (e.g. a peer's derived text-color class). When set it + // overrides the status color: the plug shape carries the status, the color carries + // identity. Left unset for connectors, which have no per-entry color. + accentClass?: string | null }>() -// Connected → success; unreachable (failed to initialize, has an error) → error; -// still handshaking → warning. A soft halo ring gives the live console a pulse -// without any custom CSS. -const dot = computed(() => { +// Shape conveys status: connected → plugged together, otherwise → unplugged. +const icon = computed(() => props.initialized ? 'i-ph-plugs-connected' : 'i-ph-plugs') + +// Color conveys identity when an accent is given, else falls back to the status color. +const color = computed(() => { + if (props.accentClass) + return props.accentClass if (props.initialized) - return 'bg-success ring-success/20' + return 'text-success' if (props.error) - return 'bg-error ring-error/20' - return 'bg-warning ring-warning/20' + return 'text-error' + return 'text-warning' }) + +// Still handshaking (not initialized, no error yet): pulse to signal it's in progress. +const connecting = computed(() => !props.initialized && !props.error) diff --git a/apps/ui/app/components/ConnectorCard.vue b/apps/ui/app/components/ConnectorCard.vue index 6075a0f..72b360d 100644 --- a/apps/ui/app/components/ConnectorCard.vue +++ b/apps/ui/app/components/ConnectorCard.vue @@ -7,6 +7,10 @@ defineProps<{ initialized: boolean error?: string | null status: { color: BadgeProps['color'], label: string } + to?: string + // Optional identity accent (e.g. a peer's derived text-color class) applied to the + // status icon. Left unset for connectors, which have no per-entry color. + accentClass?: string | null }>() defineEmits<{ edit: [], remove: [] }>() @@ -14,7 +18,7 @@ defineEmits<{ edit: [], remove: [] }>() + + + + +
diff --git a/apps/ui/app/components/PeersSection.vue b/apps/ui/app/components/PeersSection.vue index d6ed9ae..d49beef 100644 --- a/apps/ui/app/components/PeersSection.vue +++ b/apps/ui/app/components/PeersSection.vue @@ -3,9 +3,7 @@ import type { BadgeProps } from '@nuxt/ui' import type { PeerInput, PeerItem } from '~/types/management' const { request, extractError } = useManagement() - -const { data, pending, error, refresh } = await useAsyncData('peers', () => - request<{ peers: PeerItem[] }>('config/peers')) +const { settings, pending, error, reload } = useSettings() const showForm = ref(false) const editTarget = ref(null) @@ -32,7 +30,8 @@ function sortKey(peer: PeerItem) { return 1 return 2 } -const peers = computed(() => [...(data.value?.peers ?? [])].sort((a, b) => sortKey(a) - sortKey(b))) +const peers = computed(() => [...(settings.value?.peers ?? [])].sort((a, b) => sortKey(a) - sortKey(b))) +const peerTextClass = (id: string) => settings.value?.peerColors.get(id)?.text ?? peerColorTextClass(id) const connected = computed(() => peers.value.filter(p => p.initialized).length) const unreachable = computed(() => peers.value.filter(p => !p.initialized && p.initializationError).length) @@ -72,7 +71,7 @@ async function submit(input: PeerInput, force = false) { else await request('config/peers', { method: 'POST', body: input, query }) showForm.value = false - await refresh() + await reload() } catch (err) { formError.value = extractError(err, 'Could not save the peer.') @@ -90,7 +89,7 @@ async function confirmDelete() { try { await request(`config/peers/${confirmTarget.value.id}`, { method: 'DELETE' }) confirmTarget.value = null - await refresh() + await reload() } catch (err) { deleteError.value = extractError(err, 'Could not remove the peer.') @@ -107,14 +106,14 @@ async function confirmDelete() { - + -

+

Loading…

- +

@@ -124,7 +123,7 @@ async function confirmDelete() {

-
+

{{ connected }} of {{ peers.length }} connected - + -

+

Loading…

- +

@@ -128,7 +126,7 @@ const destinations = computed(() => servers.value.filter(s => s.destination).len

-
+

{{ connected }} of {{ servers.length }} connected diff --git a/apps/ui/app/types/management.ts b/apps/ui/app/types/management.ts index a3a0ad3..9749c86 100644 --- a/apps/ui/app/types/management.ts +++ b/apps/ui/app/types/management.ts @@ -45,6 +45,68 @@ export interface DownloadItem { expectedBytesMismatch: boolean } +export interface TmdbMetadata { + tmdbId: number + title: string + overview: string | null + year: number | null + rating: number | null + posterUrl: string | null + backdropUrl: string | null + genres: string[] +} + +export interface CatalogRelease { + id: string + title: string + filename: string + size: number + quality?: { name?: string, source?: string, resolution?: number } + season?: number + episode?: number +} + +export interface CatalogTitlePeer { + id: string + name: string + releaseCount: number + totalSize: number + releases: CatalogRelease[] +} + +export interface CatalogTitle { + key: string + mediaType: 'movie' | 'tv' + tmdbId?: number + imdbId?: string + tvdbId?: number + displayTitle: string + // Totals across every peer that carries this title. + releaseCount: number + totalSize: number + metadata?: TmdbMetadata | null + peers: CatalogTitlePeer[] +} + +export interface CatalogResponse { + peers: Array<{ id: string, name: string }> + titles: CatalogTitle[] +} + +export interface RequestServerOption { + id: string + name: string + type: 'radarr' | 'sonarr' + mediaType: 'movie' | 'tv' + rootFolders: Array<{ path: string, freeSpace?: number }> +} + +export interface CatalogRequestPayload { + peerId: string + serverId: string + rootFolderPath: string +} + export interface Overview { peers: { total: number, initialized: number, items: PeerItem[] } servers: { total: number, initialized: number, sources: number, destinations: number, items: OverviewServerItem[] } @@ -85,6 +147,7 @@ export interface ServerInput { export interface JackConfig { internalUrl: string apiKey?: SecretRef | null + tmdbApiKey?: SecretRef | null } export interface ApiKey { diff --git a/apps/ui/app/utils/peerColor.ts b/apps/ui/app/utils/peerColor.ts new file mode 100644 index 0000000..adf0ab0 --- /dev/null +++ b/apps/ui/app/utils/peerColor.ts @@ -0,0 +1,62 @@ +// A stable accent color per peer, derived purely from the peer id so it stays the +// same across server restarts and clients with no persistence. The palette is Nuxt +// UI's primary colors minus black (too plain), indigo (Jack's own brand color), and +// green/emerald/rose/red (too close to the connection status green/red). Amber/yellow +// resemble the connecting status, but that only shows briefly and pulses, so it's fine. +// Each entry pairs the `bg-`/`text-` class as literals (so Tailwind's scanner emits +// them) and shares one index, so the filled dot and the icon color never drift. +// Ordered to step ~5 hues around the wheel rather than spectrally, so peers assigned +// consecutive slots get visibly different colors instead of neighboring shades. +const PEER_PALETTE = [ + { dot: 'bg-orange-500', text: 'text-orange-500' }, + { dot: 'bg-cyan-500', text: 'text-cyan-500' }, + { dot: 'bg-fuchsia-500', text: 'text-fuchsia-500' }, + { dot: 'bg-lime-500', text: 'text-lime-500' }, + { dot: 'bg-violet-500', text: 'text-violet-500' }, + { dot: 'bg-amber-500', text: 'text-amber-500' }, + { dot: 'bg-sky-500', text: 'text-sky-500' }, + { dot: 'bg-pink-500', text: 'text-pink-500' }, + { dot: 'bg-teal-500', text: 'text-teal-500' }, + { dot: 'bg-purple-500', text: 'text-purple-500' }, + { dot: 'bg-yellow-500', text: 'text-yellow-500' }, + { dot: 'bg-blue-500', text: 'text-blue-500' }, +] as const + +// FNV-1a: small, dependency-free, and well-distributed for short ids. +function hashId(id: string): number { + let h = 2166136261 + for (let i = 0; i < id.length; i++) { + h ^= id.charCodeAt(i) + h = Math.imul(h, 16777619) + } + return h >>> 0 +} + +export interface PeerColor { dot: string, text: string } + +function paletteFor(id: string): PeerColor { + return PEER_PALETTE[hashId(id) % PEER_PALETTE.length]! +} + +// Assign palette colors by each peer's position in the sorted, de-duplicated id set. +// This keeps colors distinct (up to the palette size) and identical across any view +// that shares the same peer set, instead of risking per-id hash collisions. Stable +// across restarts; a peer's color only shifts when the set of peers itself changes. +export function buildPeerColorMap(ids: Iterable): Map { + const map = new Map() + const unique = [...new Set(ids)].sort() + unique.forEach((id, i) => { + map.set(id, PEER_PALETTE[i % PEER_PALETTE.length]!) + }) + return map +} + +/** Tailwind background class for a peer's filled accent dot, stable for a given id. */ +export function peerColorClass(id: string): string { + return paletteFor(id).dot +} + +/** Tailwind text-color class for a peer's accent (e.g. a status icon), stable for a given id. */ +export function peerColorTextClass(id: string): string { + return paletteFor(id).text +} diff --git a/apps/ui/nuxt.config.ts b/apps/ui/nuxt.config.ts index 3984b63..6b545a4 100644 --- a/apps/ui/nuxt.config.ts +++ b/apps/ui/nuxt.config.ts @@ -1,4 +1,6 @@ // https://nuxt.com/docs/api/configuration/nuxt-config +import pkg from './package.json' + export default defineNuxtConfig({ compatibilityDate: '2025-01-01', devtools: { enabled: false }, @@ -42,5 +44,11 @@ export default defineNuxtConfig({ // Seals the cookie that holds the management key in cookie mode. MUST be set // (>= 32 chars) in production; the default is a clearly-insecure dev value. sessionKey: 'dev-insecure-session-key-change-me-please-1234', + + // Browser-exposed: the app version, baked from package.json at build time and + // shown in Settings → About. + public: { + appVersion: pkg.version, + }, }, }) diff --git a/apps/ui/package.json b/apps/ui/package.json index 63db74d..8a4f4d8 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -1,6 +1,7 @@ { "name": "@jack/ui", "type": "module", + "version": "0.1.0", "private": true, "scripts": { "dev": "nuxt dev", diff --git a/apps/ui/public/logflix.svg b/apps/ui/public/logflix.svg new file mode 100644 index 0000000..d1d6b00 --- /dev/null +++ b/apps/ui/public/logflix.svg @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/apps/ui/public/tmdb.svg b/apps/ui/public/tmdb.svg new file mode 100644 index 0000000..4b21ded --- /dev/null +++ b/apps/ui/public/tmdb.svg @@ -0,0 +1 @@ +Asset 3 \ No newline at end of file diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..ca5c16e --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "skills": { + "tmdb-api": { + "source": "ranisalt/skills", + "sourceType": "github", + "skillPath": "tmdb-api/SKILL.md", + "computedHash": "42a19223d669b90c49ddd08e70ed4c72efaafac53f56913c9456a3fc538e6c6f" + } + } +}