From 258a98bbbbefd89fda6b3093176adbb78a159bc1 Mon Sep 17 00:00:00 2001 From: Jelmer de Wit <1598297+jdwit@users.noreply.github.com> Date: Mon, 8 Jun 2026 09:16:11 +0200 Subject: [PATCH 1/2] feat: videos categories command to list YouTube category IDs Fetches assignable video categories from the YouTube Data API (videoCategories.list) for a given region (default US). Output as table or JSON. Referenced from the sidecar docs so users no longer need a static category-ID table that goes stale. --- docs/videos.md | 2 +- src/ytstudio/commands/videos.py | 30 +++++++++++++++ tests/test_videos.py | 67 +++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) diff --git a/docs/videos.md b/docs/videos.md index e6d4397..4c9c957 100644 --- a/docs/videos.md +++ b/docs/videos.md @@ -97,7 +97,7 @@ description: | privacy: private # private | unlisted | public publish_at: 2026-06-01T10:00:00+02:00 # optional; forces privacy=private tags: [travel, vlog] -category_id: "22" # YouTube category id; default 22 (People & Blogs) +category_id: "22" # YouTube category id; default 22 (People & Blogs). Run `ytstudio videos categories` for the full list. default_language: nl default_audio_language: nl made_for_kids: false diff --git a/src/ytstudio/commands/videos.py b/src/ytstudio/commands/videos.py index 4ff132e..102bd28 100644 --- a/src/ytstudio/commands/videos.py +++ b/src/ytstudio/commands/videos.py @@ -492,4 +492,34 @@ def search_replace( console.print(f"\n[bold]Done:[/bold] {success} updated, {failed} failed") +@app.command() +def categories( + region: str = typer.Option("US", "--region", "-r", help="Region code (ISO 3166-1 alpha-2)"), + output: str = typer.Option("table", "--output", "-o", help="Output format: table, json"), +): + """List YouTube video categories assignable to uploads""" + service = get_data_service() + response = api(service.videoCategories().list(part="snippet", regionCode=region.upper())) + + items = [ + {"id": item["id"], "title": item["snippet"]["title"]} + for item in response.get("items", []) + if item["snippet"].get("assignable", False) + ] + items.sort(key=lambda c: int(c["id"])) + + if output == "json": + print(json.dumps(items, indent=2)) + return + + table = create_table() + table.add_column("ID", style="yellow", justify="right") + table.add_column("Title", style="cyan") + + for c in items: + table.add_row(c["id"], c["title"]) + + console.print(table) + + app.command("upload")(_upload_cmd) diff --git a/tests/test_videos.py b/tests/test_videos.py index e55a0d0..e1e32c1 100644 --- a/tests/test_videos.py +++ b/tests/test_videos.py @@ -1,3 +1,4 @@ +import json from unittest.mock import MagicMock, patch import typer @@ -133,6 +134,72 @@ def test_list_scheduled_filters_to_future_private(self, mock_auth): assert "Public Already" not in result.stdout +class TestCategories: + def _setup(self, mock_auth, items): + categories_list = MagicMock() + categories_list.execute.return_value = {"items": items} + mock_auth.videoCategories.return_value.list.return_value = categories_list + return categories_list + + def _cat(self, cat_id, title, assignable=True): + return {"id": cat_id, "snippet": {"title": title, "assignable": assignable}} + + def test_lists_assignable_categories(self, mock_auth): + self._setup( + mock_auth, + [ + self._cat("22", "People & Blogs"), + self._cat("10", "Music"), + self._cat("18", "Short Movies", assignable=False), + ], + ) + + result = runner.invoke(app, ["videos", "categories"]) + + assert result.exit_code == 0 + assert "People & Blogs" in result.stdout + assert "Music" in result.stdout + assert "Short Movies" not in result.stdout + + def test_defaults_to_us_region(self, mock_auth): + self._setup(mock_auth, [self._cat("22", "People & Blogs")]) + + runner.invoke(app, ["videos", "categories"]) + + mock_auth.videoCategories.return_value.list.assert_called_with( + part="snippet", regionCode="US" + ) + + def test_region_flag_uppercased(self, mock_auth): + self._setup(mock_auth, [self._cat("22", "People & Blogs")]) + + result = runner.invoke(app, ["videos", "categories", "--region", "nl"]) + + assert result.exit_code == 0 + mock_auth.videoCategories.return_value.list.assert_called_with( + part="snippet", regionCode="NL" + ) + + def test_json_output(self, mock_auth): + self._setup( + mock_auth, + [ + self._cat("22", "People & Blogs"), + self._cat("10", "Music"), + ], + ) + + result = runner.invoke(app, ["videos", "categories", "-o", "json"]) + + assert result.exit_code == 0 + + payload = json.loads(result.stdout) + assert payload == [ + {"id": "10", "title": "Music"}, + {"id": "22", "title": "People & Blogs"}, + ] + + class TestSearchReplace: def test_dry_run_shows_preview(self, mock_auth): videos = [make_search_video("vid1", "OLDNAME Episode 1")] From 1001f5137651935630e118c7c4e995bf6cf2731d Mon Sep 17 00:00:00 2001 From: Jelmer de Wit <1598297+jdwit@users.noreply.github.com> Date: Mon, 8 Jun 2026 09:39:20 +0200 Subject: [PATCH 2/2] feat!: require explicit category_id in upload sidecar The previous default of "22" (People & Blogs) was our choice, not a YouTube-imposed default. Hiding it behind a default made it easy to upload a batch into the wrong category by accident. UploadSpec now requires category_id; sidecars without it fail validation upfront. --- docs/videos.md | 2 +- src/ytstudio/upload_pipeline.py | 2 +- tests/test_upload_command.py | 1 + tests/test_upload_pipeline.py | 22 +++++++++++++++++++--- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/docs/videos.md b/docs/videos.md index 4c9c957..adc84ae 100644 --- a/docs/videos.md +++ b/docs/videos.md @@ -97,7 +97,7 @@ description: | privacy: private # private | unlisted | public publish_at: 2026-06-01T10:00:00+02:00 # optional; forces privacy=private tags: [travel, vlog] -category_id: "22" # YouTube category id; default 22 (People & Blogs). Run `ytstudio videos categories` for the full list. +category_id: "22" # YouTube category id; required. Run `ytstudio videos categories` for the full list. default_language: nl default_audio_language: nl made_for_kids: false diff --git a/src/ytstudio/upload_pipeline.py b/src/ytstudio/upload_pipeline.py index 80b0548..a37fd2f 100644 --- a/src/ytstudio/upload_pipeline.py +++ b/src/ytstudio/upload_pipeline.py @@ -25,7 +25,7 @@ class UploadSpec(BaseModel): privacy: Privacy = Privacy.private publish_at: datetime | None = None tags: list[str] = Field(default_factory=list) - category_id: str = "22" # YouTube category 22 = People & Blogs + category_id: str = Field(min_length=1) default_language: str | None = None default_audio_language: str | None = None made_for_kids: bool = False diff --git a/tests/test_upload_command.py b/tests/test_upload_command.py index fefebd6..7b0f7ca 100644 --- a/tests/test_upload_command.py +++ b/tests/test_upload_command.py @@ -15,6 +15,7 @@ Hello privacy: private tags: [sample] +category_id: "22" """ diff --git a/tests/test_upload_pipeline.py b/tests/test_upload_pipeline.py index 45c409f..44c01b7 100644 --- a/tests/test_upload_pipeline.py +++ b/tests/test_upload_pipeline.py @@ -21,7 +21,7 @@ def test_minimal_valid_spec(): - spec = UploadSpec(title="Hello", description="World") + spec = UploadSpec(title="Hello", description="World", category_id="22") assert spec.title == "Hello" assert spec.description == "World" assert spec.privacy == "private" @@ -32,10 +32,21 @@ def test_minimal_valid_spec(): assert spec.uploaded_at is None +def test_category_id_required(): + with pytest.raises(ValidationError, match="category_id"): + UploadSpec(title="x", description="x") + + +def test_category_id_empty_rejected(): + with pytest.raises(ValidationError, match="category_id"): + UploadSpec(title="x", description="x", category_id="") + + def test_publish_at_forces_privacy_to_private(): spec = UploadSpec( title="x", description="x", + category_id="22", privacy="public", publish_at="2099-01-01T10:00:00+02:00", ) @@ -47,6 +58,7 @@ def test_publish_at_must_be_future(): UploadSpec( title="x", description="x", + category_id="22", publish_at="2000-01-01T10:00:00+02:00", ) @@ -56,6 +68,7 @@ def test_publish_at_requires_timezone(): UploadSpec( title="x", description="x", + category_id="22", publish_at="2099-01-01T10:00:00", # naive ) @@ -85,6 +98,7 @@ def _write(p: Path, body: str = "") -> Path: title: Sample description: | Sample body +category_id: "22" """ @@ -165,7 +179,7 @@ def test_discover_already_uploaded_flagged(tmp_path): _write(tmp_path / "done.mp4") _write( tmp_path / "done.yaml", - "title: Done\ndescription: ok\nvideo_id: abc123\n", + 'title: Done\ndescription: ok\ncategory_id: "22"\nvideo_id: abc123\n', ) jobs = discover(tmp_path) @@ -225,7 +239,7 @@ def test_validate_jobs_collects_all_errors(tmp_path): def test_body_minimal(): - spec = UploadSpec(title="Hi", description="Body") + spec = UploadSpec(title="Hi", description="Body", category_id="22") body = to_youtube_body(spec) assert body["snippet"]["title"] == "Hi" @@ -241,6 +255,7 @@ def test_body_with_publish_at_uses_iso(): spec = UploadSpec( title="Scheduled", description="x", + category_id="22", publish_at="2099-06-01T19:00:00+02:00", ) body = to_youtube_body(spec) @@ -256,6 +271,7 @@ def test_body_includes_languages_and_tags(): spec = UploadSpec( title="Localised", description="x", + category_id="22", tags=["a", "b"], default_language="nl", default_audio_language="nl",