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
12 changes: 7 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ name: CI
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
branches: [main]

Expand All @@ -14,10 +15,10 @@ jobs:
python-version: ["3.12", "3.13"]

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6

- name: Install uv
uses: astral-sh/setup-uv@v4
uses: astral-sh/setup-uv@v7

- name: Set up Python ${{ matrix.python-version }}
run: uv python install ${{ matrix.python-version }}
Expand All @@ -36,16 +37,17 @@ jobs:
publish:
needs: test
runs-on: ubuntu-latest
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
if: startsWith(github.ref, 'refs/tags/v')
environment: pypi

permissions:
id-token: write

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6

- name: Install uv
uses: astral-sh/setup-uv@v4
uses: astral-sh/setup-uv@v7

- name: Build package
run: uv build
Expand Down
20 changes: 16 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,27 @@
# ytstudio
# YT Studio CLI

[![CI](https://github.com/jdwit/ytstudio/actions/workflows/ci.yml/badge.svg)](https://github.com/jdwit/ytstudio/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/ytstudio-cli)](https://pypi.org/project/ytstudio-cli/)
[![Python](https://img.shields.io/pypi/pyversions/ytstudio-cli)](https://pypi.org/project/ytstudio-cli/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

Manage and analyze your YouTube channel from the terminal. Ideal for agent workflows and automation.
Manage and analyze your YouTube channel from the terminal. Ideal for automation and AI workflows.

![demo](demo.gif)

## Motivation

I built this tool to bulk update video titles on my channel, something YouTube Studio doesn't support. It uses the YouTube Data API for search-and-replace operations, plus analytics and other channel management features. Simple and scriptable for automating common tasks.
I built this because I needed to bulk update video titles for a YouTube channel I manage with 300+ videos. YouTube
Studio does not support bulk search-replace operations, which made it a tedious manual process. This tool uses the
YouTube Data API to perform bulk operations on video metadata. Furthermore, it provides features for analytics and
comment moderation, all accesible from the command line.

## Installation

I recommend the excellent [uv](https://uv.io/) tool for installation:

```bash
uv tool install ytstudio
uv tool install ytstudio-cli
```

## Setup
Expand All @@ -40,3 +47,8 @@ ytstudio login
```

Credentials stored in `~/.config/ytstudio/`.

## Disclaimer

This project is not affiliated with or endorsed by Google. YouTube and YouTube Studio are trademarks of Google.
All channel data is accessed exclusively through the official [YouTube Data API](https://developers.google.com/youtube/v3) and [YouTube Analytics API](https://developers.google.com/youtube/analytics).
Binary file added demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 16 additions & 4 deletions src/ytstudio/auth.py → src/ytstudio/api.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import typer
from google.auth.exceptions import RefreshError
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
Expand All @@ -25,11 +26,11 @@ def handle_api_error(error: HttpError) -> None:
"Quota resets at midnight Pacific Time (PT).\n"
"See: https://developers.google.com/youtube/v3/guides/quota_and_compliance_audits"
)
raise SystemExit(1)
raise SystemExit(1) from None

if reason == "forbidden":
console.print("[red]Access denied. You may not have permission for this action.[/red]")
raise SystemExit(1)
raise SystemExit(1) from None

# Re-raise for other errors
raise error
Expand All @@ -45,6 +46,11 @@ def api(request):
return request.execute()
except HttpError as e:
handle_api_error(e)
except RefreshError:
console.print(
"[red]Session expired or revoked.[/red] Run [bold]ytstudio login[/bold] to re-authenticate."
)
raise SystemExit(1) from None


# YouTube API scopes
Expand All @@ -58,7 +64,7 @@ def api(request):
def authenticate() -> None:
if not CLIENT_SECRETS_FILE.exists():
console.print("[red]No client secrets found. Run 'ytstudio init' first.[/red]")
raise SystemExit(1)
raise SystemExit(1) from None

console.print("[bold]Authenticating with YouTube...[/bold]\n")

Expand Down Expand Up @@ -112,7 +118,13 @@ def get_credentials() -> Credentials | None:
)

if credentials.expired and credentials.refresh_token:
credentials.refresh(Request())
try:
credentials.refresh(Request())
except RefreshError:
console.print(
"[red]Session expired or revoked.[/red] Run [bold]ytstudio login[/bold] to re-authenticate."
)
raise SystemExit(1) from None
# Save refreshed credentials
creds_data["token"] = credentials.token
save_credentials(creds_data)
Expand Down
Loading