diff --git a/tmdb/SKILL.md b/tmdb/SKILL.md index a3447c3..ba3157b 100644 --- a/tmdb/SKILL.md +++ b/tmdb/SKILL.md @@ -31,7 +31,7 @@ tmdb tv search --term "severance" --limit 5 tmdb find tt0111161 --source imdb_id --json ``` -`find` accepts the current external sources `imdb_id`, `tvdb_id`, `wikidata_id`, `facebook_id`, `instagram_id`, `tiktok_id`, `twitter_id`, and `youtube_id`. Its response is split into `movie_results`, `tv_results`, `person_results`, `tv_season_results`, and `tv_episode_results`. +`--source` accepts one of the official external-source values: `imdb_id`, `facebook_id`, `instagram_id`, `tvdb_id`, `tiktok_id`, `twitter_id`, `wikidata_id`, and `youtube_id`. Freebase lookups are not supported: the retired `freebase_mid` and `freebase_id` values are rejected. The response is split into `movie_results`, `tv_results`, `person_results`, `tv_season_results`, and `tv_episode_results`. ### Details and enrichment @@ -49,6 +49,7 @@ tmdb movie discover --genre horror --rating 7 --limit 10 tmdb movie discover --genre horror --certification R --from 2024-01-01 --to 2024-12-31 tmdb trending --type all --window week --limit 20 --json tmdb genre list --type movie --json +tmdb genre list --type tv --json tmdb certification --json ``` @@ -95,7 +96,7 @@ Put `--json` before or after the subcommand. JSON search output has `results` an - **Credential duality:** `api_key` and Bearer are alternatives, not values to mix. A rejected credential commonly produces HTTP 401, `status_code: 7`, and `Invalid API key: You must be granted a valid key.` Permission failures use code 3. Code 33 means an invalid request token, not this API-key message. - **Pagination ceiling:** pages start at 1 and max at 500; over-limit requests fail. Search/discover access is effectively capped at 10,000 results, even where totals look larger. Rate guidance is around 40 requests/second and 429 responses should honor `Retry-After`. - **Compound syntax:** append values are comma-separated and limited to 20 calls. `watch/providers` contains a slash, so URL-encode it in curl and use jq's `.\"watch/providers\"` notation. -- **External-ID shape:** `/find/` does not return one generic `id`; inspect the appropriate nested array before choosing movie or TV detail. +- **External-ID shape:** `/find/` does not return one generic `id`; inspect the appropriate nested array before choosing movie or TV detail. Only the eight documented `external_source` values are valid, and the retired Freebase sources (`freebase_mid`, `freebase_id`) are rejected. - **Provider filters:** `with_watch_providers` requires `watch_region`; provider data carries JustWatch attribution requirements. - **Localization and images:** use `language=en-US` and a market `region` when reproducibility matters. Build image URLs from `/3/configuration`'s secure base URL, a valid size, and the returned path. diff --git a/tmdb/references/find-and-details.md b/tmdb/references/find-and-details.md index fc40de6..d7171f0 100644 --- a/tmdb/references/find-and-details.md +++ b/tmdb/references/find-and-details.md @@ -2,7 +2,7 @@ ## Start with an IMDb ID -`GET /3/find/{external_id}?external_source=imdb_id` maps a foreign identifier to TMDb objects. The current `external_source` enum includes `imdb_id`, `facebook_id`, `instagram_id`, `tvdb_id`, `tiktok_id`, `twitter_id`, `wikidata_id`, and `youtube_id`; older Freebase values are obsolete. The response has `movie_results`, `person_results`, `tv_results`, `tv_episode_results`, and `tv_season_results` arrays. Unmatched categories are empty arrays. For an IMDb movie, extract `.movie_results[0].id` before calling the movie details endpoint. +`GET /3/find/{external_id}?external_source=imdb_id` maps a foreign identifier to TMDb objects. The `external_source` value is chosen from exactly eight supported enum entries: `imdb_id`, `facebook_id`, `instagram_id`, `tvdb_id`, `tiktok_id`, `twitter_id`, `wikidata_id`, and `youtube_id`. Freebase lookups are not supported: the retired `freebase_mid` and `freebase_id` sources have been removed from the API and must not be used or documented as valid values. The response has `movie_results`, `person_results`, `tv_results`, `tv_episode_results`, and `tv_season_results` arrays. Unmatched categories are empty arrays. For an IMDb movie, extract `.movie_results[0].id` before calling the movie details endpoint. ```bash curl -s -H "Authorization: Bearer $TMDB_ACCESS_TOKEN" \ diff --git a/tmdb/scripts/test_tmdb.py b/tmdb/scripts/test_tmdb.py index e5540cf..b2a4a2a 100644 --- a/tmdb/scripts/test_tmdb.py +++ b/tmdb/scripts/test_tmdb.py @@ -63,5 +63,123 @@ class TmdbCliTests(unittest.TestCase): client.get_movie.assert_called_once_with("550", "credits,videos") +class GenreListParserTests(unittest.TestCase): + """Regression coverage for `tmdb genre list --type movie|tv`.""" + + def run_cli(self, *args): + env = os.environ.copy() + env.pop("TMDB_ACCESS_TOKEN", None) + env.pop("TMDB_API_KEY", None) + return subprocess.run([str(SCRIPT), *args], text=True, capture_output=True, env=env) + + def test_documented_nested_form_parses_and_dispatches(self): + result = self.run_cli("--dry-run", "--json", "genre", "list", "--type", "movie") + self.assertEqual(result.returncode, 0) + self.assertTrue(json.loads(result.stdout)["dry_run"]) + + def test_flat_form_is_clean_rejection_not_crash(self): + result = self.run_cli("--json", "genre", "--type", "tv") + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("Traceback", result.stderr) + self.assertIn("invalid choice", result.stderr) + + def test_missing_type_is_argument_error(self): + result = self.run_cli("--json", "genre", "list") + self.assertNotEqual(result.returncode, 0) + self.assertIn("--type", result.stderr) + + def test_dispatch_passes_tail_args_without_raw_argv_token_search(self): + cli = load_cli() + captured = {} + real_client_factory = cli.TMDBClient + + def fake_client(dry_run=False): + return real_client_factory(dry_run=dry_run) + + def fake_handler(client, args): + captured["args"] = args + + cli.cmd_genre_list = fake_handler + with patch.object(cli.sys, "argv", ["tmdb", "--dry-run", "genre", "list", "--type", "tv"]): + cli.main() + self.assertEqual(captured["args"], ["--type", "tv"]) + self.assertEqual(cli.GLOBAL_FLAGS.get("dry_run"), True) + fake_client # client construction stays credential-free + + +class TvSearchEndpointTests(unittest.TestCase): + """TV search must hit /search/tv via client.search_tv and format TV fields.""" + + def load_with_flags(self): + cli = load_cli() + cli.GLOBAL_FLAGS = {"json": True, "dry_run": False, "quiet": False, "verbose": False} + return cli + + def test_json_output_uses_search_tv_and_preserves_tv_shape(self): + cli = self.load_with_flags() + client = cli.TMDBClient() + client.search_tv = Mock(return_value={ + "page": 1, + "total_results": 1, + "results": [{"id": 9626, "name": "Poirot", "first_air_date": "1989-01-08", + "vote_average": 7.9}], + }) + client.search_movie = Mock(side_effect=AssertionError("/search/movie must not be used")) + with patch("builtins.print") as printed: + cli.cmd_tv_search(client, ["--term", "Poirot"]) + payload = json.loads(printed.call_args.args[0]) + self.assertEqual(payload["total"], 1) + self.assertEqual(payload["results"][0]["name"], "Poirot") + self.assertEqual(payload["results"][0]["first_air_date"], "1989-01-08") + client.search_tv.assert_called_once_with("Poirot") + client.search_movie.assert_not_called() + + def test_human_output_formats_name_and_first_air_date_year(self): + cli = load_cli() + cli.GLOBAL_FLAGS = {"json": False, "dry_run": False, "quiet": False, "verbose": False} + client = cli.TMDBClient() + client.search_tv = Mock(return_value={ + "total_results": 1, + "results": [{"id": 9626, "name": "Poirot", "first_air_date": "1989-01-08", + "vote_average": 7.9}], + }) + with patch("builtins.print") as printed: + cli.cmd_tv_search(client, ["--term", "Poirot"]) + line = printed.call_args.args[0] + self.assertIn("Poirot", line) + self.assertIn("(1989)", line) + + +class FindExternalSourceTests(unittest.TestCase): + """Exactly the official eight external_source values are accepted.""" + + def test_all_official_sources_are_accepted_parameterized(self): + cli = load_cli() + cli.GLOBAL_FLAGS = {"json": True, "dry_run": False, "quiet": False, "verbose": False} + self.assertEqual(len(cli.EXTERNAL_SOURCES), 8) + for source in cli.EXTERNAL_SOURCES: + with self.subTest(source=source): + client = cli.TMDBClient() + client.find_external = Mock(return_value={}) + with patch("builtins.print"): + cli.cmd_find(client, [f"ext-{source}", "--source", source]) + client.find_external.assert_called_once_with(f"ext-{source}", source) + + def test_freebase_sources_are_rejected_parameterized(self): + for retired in ("freebase_mid", "freebase_id"): + with self.subTest(source=retired): + result = self.run_cli("--json", "find", "ABC123", "--source", retired) + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("Traceback", result.stderr) + self.assertIn(retired, result.stderr) + self.assertIn("invalid choice", result.stderr) + + def run_cli(self, *args): + env = os.environ.copy() + env.pop("TMDB_ACCESS_TOKEN", None) + env.pop("TMDB_API_KEY", None) + return subprocess.run([str(SCRIPT), *args], text=True, capture_output=True, env=env) + + if __name__ == "__main__": unittest.main() diff --git a/tmdb/scripts/tmdb b/tmdb/scripts/tmdb index c8eba31..a626ace 100755 --- a/tmdb/scripts/tmdb +++ b/tmdb/scripts/tmdb @@ -26,6 +26,20 @@ ENV_SERVER = os.getenv("TMDB_SERVER", DEFAULT_SERVER) QUIET = False GLOBAL_FLAGS: Dict[str, Any] = {"json": False, "dry_run": False, "quiet": False, "verbose": False} +# Official external_source values for GET /find/{external_id}, per the current +# TMDb developer documentation. Retired Freebase sources (freebase_mid, +# freebase_id) are deliberately excluded: the API rejects them. +EXTERNAL_SOURCES: Tuple[str, ...] = ( + "imdb_id", + "facebook_id", + "instagram_id", + "tvdb_id", + "tiktok_id", + "twitter_id", + "wikidata_id", + "youtube_id", +) + def log(msg): if not QUIET and not GLOBAL_FLAGS.get("json", False): @@ -190,6 +204,22 @@ def cmd_movie_search(client, args): {"total": data.get("total_results"), "results": results}) +def cmd_tv_search(client, args): + p = argparse.ArgumentParser(prog="tmdb tv search") + p.add_argument("--term", "-t", required=True) + p.add_argument("--limit", type=int, default=10) + parsed, _ = p.parse_known_args(args) + if client.dry_run: + return emit(f"[dry-run] Search TV shows: {parsed.term}", {"dry_run": True}) + data = client.search_tv(parsed.term) or {} + results = data.get("results", [])[:parsed.limit] + if not results: + return emit("No TV shows found.", {"results": []}) + lines = [fmt_tv(r, i+1) for i, r in enumerate(results)] + emit(f"{data.get('total_results', len(results))} result(s):\n" + "\n".join(lines), + {"total": data.get("total_results"), "results": results}) + + def cmd_movie_detail(client, args): p = argparse.ArgumentParser(prog="tmdb movie detail") p.add_argument("movie_id") @@ -204,10 +234,8 @@ def cmd_movie_detail(client, args): def cmd_find(client, args): p = argparse.ArgumentParser(prog="tmdb find") p.add_argument("external_id") - p.add_argument("--source", default="imdb_id", choices=[ - "imdb_id", "facebook_id", "instagram_id", "tvdb_id", "tiktok_id", - "twitter_id", "wikidata_id", "youtube_id", - ]) + p.add_argument("--source", default="imdb_id", choices=EXTERNAL_SOURCES, + help="external_source for /find (Freebase sources are retired)") parsed, _ = p.parse_known_args(args) if client.dry_run: return emit("[dry-run] Find external ID", {"dry_run": True}) @@ -387,12 +415,16 @@ def main(): tp = sub.add_parser("tv", help="TV operations") tsub = tp.add_subparsers(dest="action") s3 = tsub.add_parser("search", help="Search TV"); s3.add_argument("--term", "-t", required=True); s3.add_argument("--limit", type=int, default=10) - s4 = tsub.add_parser("discover", help="Discover TV"); s4.add_argument("--genre"); s4.add_argument("--rating", type=float); s4.add_argument("--from", dest="air_date_gte"); s4.add_argument("--limit", type=int, default=10) - - # flat - sub.add_parser("genre", help="List genres").add_argument("--type", required=True, choices=["movie", "tv"]) + s4 = tsub.add_parser("discover", help="Discover TV"); s4.add_argument("--genre"); s4.add_argument("--rating", type=float); s4.add_argument("--from", dest="air_date_gte"); s4.add_argument("--limit", type=int, default=10) # flat + genre_p = sub.add_parser("genre", help="List genres") + genre_sub = genre_p.add_subparsers(dest="action") + gs = genre_sub.add_parser("list", help="List genres for a media type") + gs.add_argument("--type", required=True, choices=["movie", "tv"]) sub.add_parser("certification", help="List certifications") - fp = sub.add_parser("find", help="Find by external ID"); fp.add_argument("external_id"); fp.add_argument("--source", default="imdb_id") + fp = sub.add_parser("find", help="Find by external ID") + fp.add_argument("external_id") + fp.add_argument("--source", default="imdb_id", choices=EXTERNAL_SOURCES, + help="external_source for /find (Freebase sources are retired)") tr = sub.add_parser("trending", help="Trending content") tr.add_argument("--type", default="movie", choices=["movie", "tv", "all"]) @@ -405,26 +437,33 @@ def main(): sys.exit(1) client = TMDBClient(dry_run=GLOBAL_FLAGS.get("dry_run", False)) + # Dispatch on parsed attributes; positional subcommand names are re-sliced + # from argv only so per-command parsers can reject invalid flags locally. + def slice_after(token): + return filtered_argv[filtered_argv.index(token) + 1:] if token in filtered_argv else [] - # Dispatch if args.resource == "movie": - if args.action == "search": cmd_movie_search(client, filtered_argv[filtered_argv.index("search")+1:]) - elif args.action == "discover": cmd_movie_discover(client, filtered_argv[filtered_argv.index("discover")+1:]) - elif args.action == "detail": cmd_movie_detail(client, filtered_argv[filtered_argv.index("detail")+1:]) - elif args.action == "upcoming": cmd_upcoming(client, filtered_argv[filtered_argv.index("upcoming")+1:]) + if args.action == "search": cmd_movie_search(client, slice_after("search")) + elif args.action == "discover": cmd_movie_discover(client, slice_after("discover")) + elif args.action == "detail": cmd_movie_detail(client, slice_after("detail")) + elif args.action == "upcoming": + cmd_upcoming(client, slice_after("upcoming")) else: parser.print_help() elif args.resource == "tv": - if args.action == "search": cmd_movie_search(client, filtered_argv[filtered_argv.index("search")+1:]) - elif args.action == "discover": cmd_tv_discover(client, filtered_argv[filtered_argv.index("discover")+1:]) + if args.action == "search": cmd_tv_search(client, slice_after("search")) + elif args.action == "discover": cmd_tv_discover(client, slice_after("discover")) else: parser.print_help() elif args.resource == "trending": - cmd_trending(client, filtered_argv[filtered_argv.index("trending")+1:]) + cmd_trending(client, slice_after("trending")) elif args.resource == "genre": - cmd_genre_list(client, filtered_argv[filtered_argv.index("list")+1:]) + if args.action == "list": + cmd_genre_list(client, slice_after("list")) + else: + genre_p.print_help() elif args.resource == "certification": - cmd_cert_list(client, filtered_argv[filtered_argv.index("certification")+1:]) + cmd_cert_list(client, []) elif args.resource == "find": - cmd_find(client, filtered_argv[filtered_argv.index("find")+1:]) + cmd_find(client, slice_after("find")) else: parser.print_help()