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
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,32 @@ The command prints a Google OAuth URL. Open that URL in a browser on any machine
access. The browser will then fail to load a `127.0.0.1` page; this is expected. Copy the full URL
from the browser address bar, paste it back into the terminal, and ytstudio will finish the login.

## Managing multiple channels

If you run more than one YouTube channel, ytstudio stores each login under its own named profile.
Every command (`videos`, `analytics`, `comments`, ...) operates on the active channel.

```bash
ytstudio profile add work # authenticate a new channel and make it active
ytstudio profile add personal # add another
ytstudio profile list # show profiles; the active one is marked with *
ytstudio profile use work # switch the active profile
ytstudio profile status work # auth status for one profile (defaults to active)
ytstudio profile remove personal
```

For scripting you can override the active channel per command without switching:

```bash
YTSTUDIO_PROFILE=work ytstudio videos list
```

Credentials live in `~/.config/ytstudio-cli/profiles/<name>/` with owner-only (`600`) permissions.
The shared OAuth client secrets stay at the top level, so `ytstudio init` is only needed once.
A plain `ytstudio login` (no `profile add`) authenticates the active profile, which is `default`
on a fresh setup. Existing single-channel installs are migrated to the `default` profile
automatically on first run.

## API quota

The YouTube Data API enforces a default quota of 10_000 units per project per day. Most read
Expand Down
68 changes: 49 additions & 19 deletions src/ytstudio/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
from ytstudio.config import (
CLIENT_SECRETS_FILE,
clear_credentials,
get_active_profile,
load_credentials,
save_credentials,
save_profile_meta,
)
from ytstudio.ui import console, success_message

Expand Down Expand Up @@ -73,7 +75,7 @@ def _create_flow() -> InstalledAppFlow:
)


def _save_credentials(credentials: Credentials) -> None:
def _save_credentials(credentials: Credentials, profile: str | None = None) -> None:
creds_data = {
"token": credentials.token,
"refresh_token": credentials.refresh_token,
Expand All @@ -82,17 +84,36 @@ def _save_credentials(credentials: Credentials) -> None:
"client_secret": credentials.client_secret,
"scopes": credentials.scopes,
}
save_credentials(creds_data)
save_credentials(creds_data, profile)


def _show_login_success(credentials: Credentials) -> None:
service = build("youtube", "v3", credentials=credentials)
response = service.channels().list(part="snippet", mine=True).execute()
def _fetch_channel_info(credentials: Credentials) -> dict | None:
# Best-effort: a quota/network error here must not fail a successful login.
try:
service = build("youtube", "v3", credentials=credentials)
response = service.channels().list(part="snippet", mine=True).execute()
except Exception:
return None

items = response.get("items")
if not items:
return None

snippet = items[0]["snippet"]
return {
"id": items[0].get("id", ""),
"title": snippet.get("title", ""),
"custom_url": snippet.get("customUrl", ""),
}


def _show_login_success(credentials: Credentials, profile: str) -> None:
info = _fetch_channel_info(credentials)

console.print()
if response.get("items"):
channel = response["items"][0]["snippet"]
success_message(f"Logged in as: {channel['title']}")
if info:
save_profile_meta(profile, info)
success_message(f"Logged in as: {info['title']}")
else:
success_message("Authentication successful")

Expand Down Expand Up @@ -143,11 +164,15 @@ def _authenticate_headless() -> Credentials:
return flow.credentials


def authenticate(headless: bool = False) -> None:
def authenticate(headless: bool = False, profile: str | None = None) -> None:
if not CLIENT_SECRETS_FILE.exists():
console.print("[red]No client secrets found. Run 'ytstudio init' first.[/red]")
raise SystemExit(1) from None

# Resolve the target profile up-front: a profile switch during a long OAuth
# flow must not redirect the freshly minted credentials to a different one.
profile = profile or get_active_profile()

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

if headless:
Expand All @@ -160,12 +185,13 @@ def authenticate(headless: bool = False) -> None:
open_browser=True,
)

_save_credentials(credentials)
_show_login_success(credentials)
_save_credentials(credentials, profile)
_show_login_success(credentials, profile)


def get_credentials() -> Credentials | None:
creds_data = load_credentials()
def get_credentials(profile: str | None = None) -> Credentials | None:
profile = profile or get_active_profile()
creds_data = load_credentials(profile)
if not creds_data:
return None

Expand All @@ -188,27 +214,31 @@ def get_credentials() -> Credentials | None:
raise SystemExit(1) from None
# Save refreshed credentials
creds_data["token"] = credentials.token
save_credentials(creds_data)
save_credentials(creds_data, profile)

return credentials


def get_authenticated_service(api_name: str = "youtube", version: str = "v3"):
credentials = get_credentials()
def get_authenticated_service(
api_name: str = "youtube",
version: str = "v3",
profile: str | None = None,
):
credentials = get_credentials(profile)
if not credentials:
console.print("[red]Not authenticated. Run 'ytstudio login' first.[/red]")
raise typer.Exit(1)
return build(api_name, version, credentials=credentials)


def get_status() -> None:
creds_data = load_credentials()
def get_status(profile: str | None = None) -> None:
creds_data = load_credentials(profile)

if not creds_data:
console.print("[yellow]Not authenticated. Run 'ytstudio login' to authenticate.[/yellow]")
return

credentials = get_credentials()
credentials = get_credentials(profile)
if not credentials or not credentials.valid:
console.print(
"[yellow]Credentials expired. Run 'ytstudio login' to re-authenticate.[/yellow]"
Expand Down
96 changes: 96 additions & 0 deletions src/ytstudio/commands/profile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import typer

from ytstudio import config
from ytstudio.api import authenticate, get_status
from ytstudio.ui import console, create_table, dim, success_message

app = typer.Typer(help="Manage credential profiles (one per YouTube channel)")


@app.command()
def add(
name: str = typer.Argument(help="Name for the new profile"),
headless: bool = typer.Option(
False,
"--headless",
help="Authenticate by pasting a redirect URL from another browser",
),
):
"""Authenticate a new profile and switch to it"""
if not config.is_valid_profile_name(name):
console.print(
"[red]Invalid name. Use letters, digits, '-' and '_' "
"(must start with a letter or digit).[/red]"
)
raise typer.Exit(1)
if config.profile_exists(name):
console.print(
f"[red]Profile '{name}' already exists. "
f"Switch with 'ytstudio profile use {name}'.[/red]"
)
raise typer.Exit(1)

authenticate(headless=headless, profile=name)
config.set_active_profile(name)
success_message(f"Profile '{name}' is now active.")


@app.command("list")
def list_profiles():
"""List configured profiles"""
profiles = config.list_profiles()
if not profiles:
console.print("No profiles yet. Run 'ytstudio login' or 'ytstudio profile add <name>'.")
return

active = config.get_active_profile()
table = create_table()
table.add_column("")
table.add_column("Name")
table.add_column("YouTube channel")
table.add_column("Handle")

for name in profiles:
meta = config.load_profile_meta(name)
marker = "[green]*[/green]" if name == active else " "
title = meta.get("title") or dim("unknown (run login for this profile)")
handle = meta.get("custom_url") or ""
table.add_row(marker, name, title, handle)

console.print(table)


@app.command()
def use(name: str = typer.Argument(help="Profile to make active")):
"""Switch the active profile"""
if not config.profile_exists(name):
console.print(f"[red]No profile named '{name}'. See 'ytstudio profile list'.[/red]")
raise typer.Exit(1)
config.set_active_profile(name)
success_message(f"Active profile: {name}")


@app.command()
def status(name: str = typer.Argument(None, help="Profile name (default: active)")):
"""Show authentication status for a profile"""
target = name or config.get_active_profile()
if not config.profile_exists(target):
console.print(f"[red]No profile named '{target}'. See 'ytstudio profile list'.[/red]")
raise typer.Exit(1)
console.print(f"[bold]Profile:[/bold] {target}")
get_status(target)


@app.command()
def remove(
name: str = typer.Argument(help="Profile to remove"),
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
):
"""Remove a profile and its stored credentials"""
if not config.profile_exists(name):
console.print(f"[red]No profile named '{name}'.[/red]")
raise typer.Exit(1)
if not force and not typer.confirm(f"Remove profile '{name}' and its stored credentials?"):
raise typer.Exit()
config.remove_profile(name)
success_message(f"Profile '{name}' removed.")
Loading