Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/videos.md
Original file line number Diff line number Diff line change
Expand Up @@ -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; required. Run `ytstudio videos categories` for the full list.
default_language: nl
default_audio_language: nl
made_for_kids: false
Expand Down
30 changes: 30 additions & 0 deletions src/ytstudio/commands/videos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 1 addition & 1 deletion src/ytstudio/upload_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions tests/test_upload_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
Hello
privacy: private
tags: [sample]
category_id: "22"
"""


Expand Down
22 changes: 19 additions & 3 deletions tests/test_upload_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
)
Expand All @@ -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",
)

Expand All @@ -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
)

Expand Down Expand Up @@ -85,6 +98,7 @@ def _write(p: Path, body: str = "") -> Path:
title: Sample
description: |
Sample body
category_id: "22"
"""


Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand All @@ -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)
Expand All @@ -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",
Expand Down
67 changes: 67 additions & 0 deletions tests/test_videos.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
from unittest.mock import MagicMock, patch

import typer
Expand Down Expand Up @@ -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")]
Expand Down