mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-11 19:47:12 +03:00
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:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
c90fade381
commit
bd92ed3fca
+3
-2
@@ -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.
|
||||
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user