fix(tmdb): align CLI to genre list, tv search, and find contracts

- Replace raw-argv dispatch with parsed-attribute routing; add the
  documented nested `genre list --type movie|tv` subparser while keeping
  the flat legacy form a clean argparse rejection instead of a crash.
- Route `tv search` through client.search_tv (/search/tv) with TV
  formatting (name / first_air_date) via new cmd_tv_search.
- Gate find --source to exactly the official eight external_source
  values (imdb_id, facebook_id, instagram_id, tvdb_id, tiktok_id,
  twitter_id, wikidata_id, youtube_id); docs explicitly reject retired
  freebase_mid/freebase_id; SKILL.md documents both --type variants.

Adds parser-level regression tests for genre list, endpoint-selection
and output-shape tests for tv search, and parameterized acceptance and
rejection tests for external_source values (13 tests, 10 subtests,
green under pytest strict-markers, unittest discover, and the proxy
trap).

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
Magnus Hedemark
2026-08-26 18:29:26 -04:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent c90fade381
commit bd92ed3fca
4 changed files with 181 additions and 23 deletions
+3 -2
View File
@@ -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.
+1 -1
View File
@@ -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" \
+118
View File
@@ -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()
+59 -20
View File
@@ -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()