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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ command line.
- Upload videos from a directory using YAML sidecars for metadata and thumbnails.
- Schedule, start, stop, and update YouTube livestream broadcasts, including RTMP ingest details.
- Multi-channel profiles: manage several channels from one machine and switch per command.
- Comments moderation: list, approve, reject, and ban from the CLI.
- Comments moderation: list, reply, approve, reject, and ban from the CLI.
- Channel analytics queries via the YouTube Analytics API.
- Playlists: bulk-add by search and reorder by views with one command.

Expand Down
1 change: 1 addition & 0 deletions docs/api-quota.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ not eat into the table below.
| `search.list` | 100 units |
| `videos.update` | 50 units |
| `comments.setModerationStatus` | 50 units |
| `comments.insert` (reply) | 50 units |
| `playlists.insert` / `update` / `delete` | 50 units |
| `playlistItems.insert` / `update` / `delete` | 50 units |
| `liveBroadcasts.insert` | 50 units |
Expand Down
17 changes: 15 additions & 2 deletions docs/comments.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,20 @@ ytstudio comments reject <comment-id> --ban # also ban the author
`reject` hides the comment from public view. Pass `--ban` to also ban the
author from the channel.

## Reply

```bash
ytstudio comments reply <comment-id> --text "Thanks for watching!"
```

`reply` posts a public reply to a comment and prints the new reply id.
Replies are flat on YouTube: `<comment-id>` must be a **top-level** comment
id (the `id` shown by `comments list`), not the id of another reply. Passing
a reply id (or an otherwise invalid id) returns a clear error. Like
`publish` and `reject`, `reply` executes immediately.

!!! note "Quota"

`comments.setModerationStatus` costs about 50 quota units per call. For
larger moderation runs, see [API quota](api-quota.md).
`comments.setModerationStatus` and `comments.insert` (reply) each cost
about 50 quota units per call. For larger moderation runs, see
[API quota](api-quota.md).
31 changes: 31 additions & 0 deletions src/ytstudio/commands/comments.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,34 @@ def reject(
"""Reject comments (hide from public display)"""
count = _set_moderation_status(comment_ids, "rejected", ban_author=ban)
console.print(f"{count} comment(s) rejected")


@app.command()
def reply(
comment_id: str = typer.Argument(
help="Top-level comment ID to reply to (from 'comments list')"
),
text: str = typer.Option(..., "--text", "-t", help="Reply text"),
):
"""Reply to a top-level comment.

Replies are flat on YouTube: COMMENT_ID must be a top-level comment id
(the id shown by 'comments list'), not the id of another reply.
"""
service = get_data_service()
try:
response = api(
service.comments().insert(
part="snippet",
body={"snippet": {"parentId": comment_id, "textOriginal": text}},
)
)
except HttpError as e:
if e.resp.status in (400, 404):
console.print(
f"[red]Could not reply to '{comment_id}'.[/red] Pass a top-level "
"comment id from 'comments list' (not a reply id)."
)
raise typer.Exit(1) from None
handle_api_error(e)
console.print(f"Reply posted: {response['id']}")
7 changes: 7 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,13 @@ def create_mock_service():
}
service.commentThreads.return_value.list.return_value = comments_list

comments_insert = MagicMock()
comments_insert.execute.return_value = {
"id": "UgwReply789",
"snippet": {"parentId": MOCK_COMMENT["id"], "textOriginal": "Thanks for watching!"},
}
service.comments.return_value.insert.return_value = comments_insert

search_list = MagicMock()
search_list.execute.return_value = {
"items": [{"id": {"videoId": MOCK_VIDEO["id"]}}],
Expand Down
27 changes: 27 additions & 0 deletions tests/test_comments.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from datetime import UTC, datetime, timedelta
from unittest.mock import MagicMock

from googleapiclient.errors import HttpError
from typer.testing import CliRunner

from ytstudio.main import app
Expand Down Expand Up @@ -47,3 +49,28 @@ def test_list_comments_disabled(self, mock_auth):
)
result = runner.invoke(app, ["comments", "list", "--video", "test_video_123"])
assert result.exit_code == 1

def test_reply(self, mock_auth):
result = runner.invoke(
app, ["comments", "reply", "UgwComment123", "--text", "Thanks for watching!"]
)
assert result.exit_code == 0
assert "UgwReply789" in result.stdout
insert = mock_auth.comments.return_value.insert
body = insert.call_args.kwargs["body"]
assert body["snippet"]["parentId"] == "UgwComment123"
assert body["snippet"]["textOriginal"] == "Thanks for watching!"

def test_reply_requires_text(self, mock_auth):
result = runner.invoke(app, ["comments", "reply", "UgwComment123"])
assert result.exit_code != 0

def test_reply_invalid_parent(self, mock_auth):
resp = MagicMock()
resp.status = 400
mock_auth.comments.return_value.insert.return_value.execute.side_effect = HttpError(
resp, b'{"error": {"message": "invalid"}}'
)
result = runner.invoke(app, ["comments", "reply", "not_a_top_level_id", "--text", "hi"])
assert result.exit_code == 1
assert "top-level comment id" in result.stdout