youtube-kit 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
youtube_kit/cli.py ADDED
@@ -0,0 +1,217 @@
1
+ """`ytk`: the command-line adapter over the core operations.
2
+
3
+ Contract (relied on by agents and scripts, documented in skills/youtube/SKILL.md):
4
+ - stdout carries exactly one JSON document per successful command, nulls omitted.
5
+ - Failures print {"error": {"type", "message", "details"?}} to stderr and exit
6
+ with the error's code: 2 invalid input, 3 not found, 4 not authorized, 5 quota
7
+ exceeded, 6 forbidden, 7 network, 8 confirmation required, 9 protected,
8
+ 10 timeout, 1 anything else (type "error"). Usage errors from argument parsing exit 2 with type
9
+ "usage" (as formatted text when stderr is a terminal).
10
+ - Guarded deletes ask a human at a terminal instead of exiting 8; declining exits 1.
11
+ For agents, the exit-8 details include `rerun`, the exact command that confirms.
12
+ """
13
+
14
+ import contextlib
15
+ import json
16
+ import shlex
17
+ import sys
18
+ from collections.abc import Iterator, Sequence
19
+ from importlib.metadata import version
20
+ from typing import Annotated
21
+
22
+ import cyclopts
23
+ import pydantic_core
24
+ from cyclopts import CycloptsError
25
+
26
+ from . import core
27
+ from .core import auth, progress
28
+ from .core.config import Settings
29
+ from .core.models import AuthStatus
30
+ from .core.ops import analytics, branding, channels, comments, playlists, search, subscriptions, videos
31
+
32
+ app = cyclopts.App(
33
+ name="ytk",
34
+ help="YouTube Data and Analytics APIs from the command line. Prints JSON; see `ytk <command> --help`.",
35
+ version=version("youtube-kit"),
36
+ # No --no-X / --empty-X flags, except on update commands where "unset" and "clear" mean something.
37
+ default_parameter=cyclopts.Parameter(negative_bool=(), negative_iterable=()),
38
+ )
39
+
40
+ video = app.command(cyclopts.App(name="video", help="Look up, upload, edit, and delete videos."))
41
+ playlist = app.command(cyclopts.App(name="playlist", help="Look up and manage playlists."))
42
+ comment = app.command(cyclopts.App(name="comment", help="Read, write, and moderate comments."))
43
+ channel = app.command(cyclopts.App(name="channel", help="Look up channels, and brand your own."))
44
+ watermark = channel.command(cyclopts.App(name="watermark", help="The subscribe watermark shown over your videos."))
45
+ section = channel.command(cyclopts.App(name="section", help="Sections on a channel's home page."))
46
+ subscription = app.command(cyclopts.App(name="subscription", help="Your subscriptions to other channels."))
47
+ auth_app = app.command(cyclopts.App(name="auth", help="Authorize access to your YouTube account."))
48
+
49
+ _NEGATIVES = cyclopts.Parameter(negative_bool=None, negative_iterable=None)
50
+
51
+ app.command(search.search, name="search")
52
+ app.command(analytics.get_analytics, name="analytics")
53
+
54
+ video.command(videos.get_videos, name="get")
55
+ video.command(videos.list_video_categories, name="categories")
56
+ video.command(videos.upload_video, name="upload")
57
+ video.command(videos.get_processing_status, name="status")
58
+ video.command(videos.update_video, name="update", default_parameter=_NEGATIVES)
59
+ video.command(videos.set_thumbnail, name="thumbnail")
60
+ video.command(videos.rate_video, name="rate")
61
+ video.command(videos.get_my_ratings, name="rating")
62
+ video.command(videos.delete_video, name="delete")
63
+
64
+ playlist.command(playlists.list_playlists, name="list")
65
+ playlist.command(playlists.get_playlists, name="get")
66
+ playlist.command(playlists.list_playlist_items, name="items")
67
+ playlist.command(playlists.create_playlist, name="create")
68
+ playlist.command(playlists.update_playlist, name="update")
69
+ playlist.command(playlists.set_playlist_thumbnail, name="thumbnail")
70
+ playlist.command(playlists.delete_playlist, name="delete")
71
+ playlist.command(playlists.add_video_to_playlist, name="add")
72
+ playlist.command(playlists.remove_from_playlist, name="remove")
73
+ playlist.command(playlists.move_in_playlist, name="move")
74
+
75
+ comment.command(comments.list_comments, name="list")
76
+ comment.command(comments.list_replies, name="replies")
77
+ comment.command(comments.post_comment, name="post")
78
+ comment.command(comments.reply_to_comment, name="reply")
79
+ comment.command(comments.edit_comment, name="edit")
80
+ comment.command(comments.delete_comment, name="delete")
81
+ comment.command(comments.approve_comments, name="approve")
82
+ comment.command(comments.hold_comments, name="hold")
83
+ comment.command(comments.reject_comments, name="reject")
84
+
85
+ channel.command(channels.get_channel, name="get")
86
+ channel.command(channels.list_activities, name="activity")
87
+ channel.command(branding.update_channel, name="update", default_parameter=_NEGATIVES)
88
+ channel.command(branding.set_channel_banner, name="banner")
89
+ watermark.command(branding.set_watermark, name="set")
90
+ watermark.command(branding.remove_watermark, name="remove")
91
+ section.command(branding.list_sections, name="list")
92
+ section.command(branding.add_section, name="add")
93
+ section.command(branding.update_section, name="update")
94
+ section.command(branding.delete_section, name="delete")
95
+
96
+ subscription.command(subscriptions.list_subscriptions, name="list")
97
+ subscription.command(subscriptions.subscribe, name="add")
98
+ subscription.command(subscriptions.unsubscribe, name="remove")
99
+
100
+
101
+ @auth_app.command(name="login")
102
+ # negative_bool=None brings back --no-browser, which the app-wide default turns off.
103
+ def auth_login(*, browser: Annotated[bool, cyclopts.Parameter(negative_bool=None)] = True) -> AuthStatus:
104
+ """Authorize your YouTube account through Google's OAuth consent screen.
105
+
106
+ Needs an OAuth client of type "Desktop app" saved as client_secrets.json
107
+ (see `ytk auth status` for the path). The token is stored for both the CLI
108
+ and the MCP server.
109
+
110
+ Args:
111
+ browser: Open the consent page automatically. With --no-browser, open the printed URL yourself.
112
+ """
113
+ # The OAuth library prints the consent URL to stdout, which is reserved for JSON.
114
+ with contextlib.redirect_stdout(sys.stderr):
115
+ return auth.login(Settings.from_env(), open_browser=browser)
116
+
117
+
118
+ @auth_app.command(name="status")
119
+ def auth_status() -> AuthStatus:
120
+ """Show which credentials are configured and where they are stored. Makes no API calls."""
121
+ return auth.status(Settings.from_env())
122
+
123
+
124
+ @auth_app.command(name="logout")
125
+ def auth_logout() -> AuthStatus:
126
+ """Delete the stored OAuth token. The API key, if any, is unaffected."""
127
+ return auth.logout(Settings.from_env())
128
+
129
+
130
+ @app.command(name="mcp")
131
+ def serve_mcp() -> None:
132
+ """Run the MCP server over stdio, exposing the same operations as tools."""
133
+ from . import mcp_server # deferred: FastMCP is slow to import and only needed here
134
+
135
+ mcp_server.run()
136
+
137
+
138
+ def main(argv: Sequence[str] | None = None) -> int:
139
+ argv = list(sys.argv[1:] if argv is None else argv)
140
+ human = sys.stderr.isatty()
141
+ try:
142
+ with _terminal_progress():
143
+ result = app(argv, exit_on_error=False, print_error=human, result_action="return_value")
144
+ except CycloptsError as e:
145
+ if not human: # a person got cyclopts' formatted message already
146
+ print(json.dumps({"error": {"type": "usage", "message": str(e)}}, ensure_ascii=False), file=sys.stderr)
147
+ return 2
148
+ except core.ConfirmationRequired as e:
149
+ confirmed = _with_confirm(argv, e.details["confirm"])
150
+ if not (sys.stdin.isatty() and sys.stderr.isatty()):
151
+ e.details["rerun"] = shlex.join(["ytk", *confirmed])
152
+ return _fail(e)
153
+ if not _approved(e.details):
154
+ print("Cancelled; nothing was deleted.", file=sys.stderr)
155
+ return 1
156
+ # Rerunning fetches and checks the target again rather than trusting what was shown.
157
+ return main(confirmed)
158
+ except core.YouTubeError as e:
159
+ return _fail(e)
160
+ except KeyboardInterrupt:
161
+ return 130
162
+ except Exception as e: # noqa: BLE001 -- keep the JSON contract even for bugs and odd responses
163
+ return _fail(core.YouTubeError(f"{type(e).__name__}: {e}"))
164
+ if result is not None:
165
+ indent = 2 if sys.stdout.isatty() else None
166
+ try:
167
+ print(pydantic_core.to_json(result, indent=indent, exclude_none=True).decode())
168
+ except BrokenPipeError: # e.g. piped into head
169
+ sys.stdout = None # stop the interpreter's final flush from raising again
170
+ return 0
171
+
172
+
173
+ def _fail(error: core.YouTubeError) -> int:
174
+ body = {"type": error.kind, "message": str(error)}
175
+ if error.details:
176
+ body["details"] = error.details
177
+ print(json.dumps({"error": body}, ensure_ascii=False), file=sys.stderr)
178
+ return error.exit_code
179
+
180
+
181
+ def _with_confirm(argv: list[str], value: str) -> list[str]:
182
+ end = argv.index("--") if "--" in argv else len(argv)
183
+ return [*argv[:end], "--confirm", value, *argv[end:]]
184
+
185
+
186
+ def _approved(plan: dict) -> bool:
187
+ print(f"About to permanently delete {plan['kind']} {plan['confirm']}:", file=sys.stderr)
188
+ for key, value in plan.items():
189
+ if key not in {"kind", "confirm"}:
190
+ print(f" {key}: {str(value).replace(chr(10), chr(10) + ' ')}", file=sys.stderr)
191
+ print("Delete it? [y/N] ", end="", file=sys.stderr, flush=True)
192
+ return sys.stdin.readline().strip().lower() in {"y", "yes"}
193
+
194
+
195
+ @contextlib.contextmanager
196
+ def _terminal_progress() -> Iterator[None]:
197
+ """Show a self-overwriting percentage on stderr when a human is watching.
198
+
199
+ The line is ended on exit, before any error is printed after it.
200
+ """
201
+ if not sys.stderr.isatty():
202
+ yield
203
+ return
204
+ shown = False
205
+
206
+ def show(done: int, total: int) -> None:
207
+ nonlocal shown
208
+ shown = True
209
+ percent = 100 * done // total if total else 100
210
+ print(f"\rprogress: {percent:3d}%", end="", file=sys.stderr, flush=True)
211
+
212
+ try:
213
+ with progress.reporting_to(show):
214
+ yield
215
+ finally:
216
+ if shown:
217
+ print(file=sys.stderr)
@@ -0,0 +1,36 @@
1
+ """Transport-neutral YouTube Data API core. No printing, no exit codes, no MCP.
2
+
3
+ Operations live in `core.ops.<noun>` modules; importing this package registers them all in OPERATIONS.
4
+ """
5
+
6
+ from . import ops as ops
7
+ from .errors import (
8
+ ConfirmationRequired,
9
+ Forbidden,
10
+ InvalidRequest,
11
+ NetworkError,
12
+ NotAuthorized,
13
+ NotFound,
14
+ Protected,
15
+ QuotaExceeded,
16
+ TimedOut,
17
+ YouTubeError,
18
+ )
19
+ from .operation import OPERATIONS, OperationInfo, info
20
+
21
+ __all__ = [
22
+ "OPERATIONS",
23
+ "ConfirmationRequired",
24
+ "Forbidden",
25
+ "InvalidRequest",
26
+ "NetworkError",
27
+ "NotAuthorized",
28
+ "NotFound",
29
+ "OperationInfo",
30
+ "Protected",
31
+ "QuotaExceeded",
32
+ "TimedOut",
33
+ "YouTubeError",
34
+ "info",
35
+ "ops",
36
+ ]
@@ -0,0 +1,103 @@
1
+ """OAuth token lifecycle: login, load (with refresh), logout, status."""
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+ from google.auth.transport.requests import Request
9
+ from google.oauth2.credentials import Credentials
10
+ from google_auth_oauthlib.flow import InstalledAppFlow
11
+
12
+ from .config import Settings
13
+ from .errors import LOGIN_HINT, NotAuthorized
14
+ from .models import AuthStatus
15
+
16
+ # Full read/write access to the user's YouTube account: uploads, edits, playlists, comments.
17
+ YOUTUBE = "https://www.googleapis.com/auth/youtube.force-ssl"
18
+ # YouTube Analytics reports, revenue included.
19
+ ANALYTICS = "https://www.googleapis.com/auth/yt-analytics-monetary.readonly"
20
+ SCOPES = [YOUTUBE, ANALYTICS]
21
+
22
+
23
+ def load_credentials(settings: Settings, scope: str = YOUTUBE) -> Credentials:
24
+ """Stored OAuth credentials, refreshed and re-saved if expired. They must include `scope`."""
25
+ if not settings.token_file.exists():
26
+ raise NotAuthorized(f"No OAuth token at {settings.token_file}. {LOGIN_HINT}")
27
+ try:
28
+ info = json.loads(settings.token_file.read_text())
29
+ except ValueError as e:
30
+ raise NotAuthorized(f"OAuth token at {settings.token_file} is unreadable ({e}). {LOGIN_HINT}") from e
31
+ # The token lists the scopes Google granted; the user may have unticked some, or logged in before
32
+ # a scope was added. A refresh can't add scopes; the only fix is logging in again. Tokens from
33
+ # elsewhere may not list scopes at all; Google then answers for itself.
34
+ if "scopes" in info and scope not in info["scopes"]:
35
+ access = "analytics" if scope == ANALYTICS else "YouTube"
36
+ raise NotAuthorized(f"Your login doesn't grant {access} access. {LOGIN_HINT} Allow every requested permission.")
37
+ try:
38
+ creds = Credentials.from_authorized_user_info(info)
39
+ except ValueError as e:
40
+ raise NotAuthorized(f"OAuth token at {settings.token_file} is incomplete ({e}). {LOGIN_HINT}") from e
41
+ if creds.valid:
42
+ return creds
43
+ if not creds.refresh_token:
44
+ raise NotAuthorized(f"OAuth token at {settings.token_file} expired and cannot be refreshed. {LOGIN_HINT}")
45
+ creds.refresh(Request())
46
+ _save_token(settings.token_file, json.loads(creds.to_json()))
47
+ return creds
48
+
49
+
50
+ def login(settings: Settings, *, open_browser: bool = True) -> AuthStatus:
51
+ """Run the installed-app OAuth flow and store the token. Prints the auth URL to stdout."""
52
+ if not settings.client_secrets_file.exists():
53
+ raise NotAuthorized(
54
+ f"OAuth client secrets not found at {settings.client_secrets_file}. "
55
+ "In Google Cloud Console, enable YouTube Data API v3, create an OAuth client ID "
56
+ "of type 'Desktop app', and save its JSON to that path "
57
+ "(or point YOUTUBE_CLIENT_SECRETS at it)."
58
+ )
59
+ # Google lets users untick scopes; without this, oauthlib raises instead of returning what was granted.
60
+ os.environ.setdefault("OAUTHLIB_RELAX_TOKEN_SCOPE", "1")
61
+ flow = InstalledAppFlow.from_client_secrets_file(str(settings.client_secrets_file), SCOPES)
62
+ # prompt=consent: without it Google may omit the refresh token when re-authorizing.
63
+ creds = flow.run_local_server(port=0, open_browser=open_browser, prompt="consent")
64
+ info = json.loads(creds.to_json())
65
+ info["scopes"] = creds.granted_scopes or SCOPES # Google omits the list when all were granted
66
+ _save_token(settings.token_file, info)
67
+ return status(settings)
68
+
69
+
70
+ def logout(settings: Settings) -> AuthStatus:
71
+ """Delete the stored token. Does not revoke it with Google."""
72
+ settings.token_file.unlink(missing_ok=True)
73
+ return status(settings)
74
+
75
+
76
+ def status(settings: Settings) -> AuthStatus:
77
+ """What credentials are configured. Local check only; makes no network calls."""
78
+ try:
79
+ granted_scopes = json.loads(settings.token_file.read_text()).get("scopes")
80
+ except (OSError, ValueError, AttributeError):
81
+ granted_scopes = None
82
+ return AuthStatus(
83
+ api_key_set=settings.api_key is not None,
84
+ token_file=str(settings.token_file),
85
+ token_present=settings.token_file.exists(),
86
+ granted_scopes=granted_scopes,
87
+ client_secrets_file=str(settings.client_secrets_file),
88
+ client_secrets_present=settings.client_secrets_file.exists(),
89
+ config_file=str(settings.config_file),
90
+ )
91
+
92
+
93
+ def _save_token(path: Path, info: dict) -> None:
94
+ # Atomic replace: the CLI and a running MCP server may refresh concurrently.
95
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
96
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".token-", suffix=".json")
97
+ try:
98
+ with os.fdopen(fd, "w") as f:
99
+ json.dump(info, f)
100
+ os.replace(tmp, path)
101
+ except BaseException:
102
+ Path(tmp).unlink(missing_ok=True)
103
+ raise
@@ -0,0 +1,62 @@
1
+ """googleapiclient Resources for the YouTube Data and Analytics APIs.
2
+
3
+ Resources are cached per thread because httplib2 connections are not thread-safe
4
+ and the MCP server runs sync tools on worker threads. The OAuth client is keyed by
5
+ the token file's mtime, so a long-running server picks up `ytk auth login` without
6
+ a restart.
7
+ """
8
+
9
+ import threading
10
+
11
+ from googleapiclient.discovery import Resource, build
12
+
13
+ from . import auth
14
+ from .config import Settings
15
+ from .errors import LOGIN_HINT, NotAuthorized
16
+
17
+ _local = threading.local()
18
+
19
+
20
+ def public() -> Resource:
21
+ """Client for public data: the API key if set, otherwise the user's OAuth token."""
22
+ settings = Settings.from_env()
23
+ if settings.api_key is None:
24
+ if not settings.token_file.exists():
25
+ raise NotAuthorized(f"No credentials. Set YOUTUBE_API_KEY for public data, or: {LOGIN_HINT}")
26
+ return oauth()
27
+ return _cached("public", settings.api_key, lambda: _build(developerKey=settings.api_key))
28
+
29
+
30
+ def oauth() -> Resource:
31
+ """Client acting as the authorized user (uploads, edits, own channel)."""
32
+ settings = Settings.from_env()
33
+ return _cached("oauth", _token_key(settings), lambda: _build(credentials=auth.load_credentials(settings)))
34
+
35
+
36
+ def analytics() -> Resource:
37
+ """YouTube Analytics client for the authorized user's channel."""
38
+ settings = Settings.from_env()
39
+ return _cached(
40
+ "analytics",
41
+ _token_key(settings),
42
+ lambda: _build("youtubeAnalytics", "v2", credentials=auth.load_credentials(settings, auth.ANALYTICS)),
43
+ )
44
+
45
+
46
+ def _token_key(settings: Settings) -> int | None:
47
+ try:
48
+ return settings.token_file.stat().st_mtime_ns
49
+ except FileNotFoundError:
50
+ return None # load_credentials raises the actionable error
51
+
52
+
53
+ def _cached(slot: str, key: object, make) -> Resource:
54
+ cached = getattr(_local, slot, None)
55
+ if cached is None or cached[0] != key:
56
+ cached = (key, make())
57
+ setattr(_local, slot, cached)
58
+ return cached[1]
59
+
60
+
61
+ def _build(service: str = "youtube", version: str = "v3", **kwargs) -> Resource:
62
+ return build(service, version, cache_discovery=False, static_discovery=True, **kwargs)
@@ -0,0 +1,59 @@
1
+ """Where credentials and settings come from. Environment variables override the defaults."""
2
+
3
+ import os
4
+ import tomllib
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Self
8
+
9
+ from platformdirs import user_config_path
10
+
11
+ from .errors import InvalidRequest
12
+
13
+ APP_NAME = "youtube-kit"
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Settings:
18
+ api_key: str | None
19
+ token_file: Path
20
+ client_secrets_file: Path
21
+ config_file: Path
22
+
23
+ @classmethod
24
+ def from_env(cls) -> Self:
25
+ config_dir = user_config_path(APP_NAME)
26
+ return cls(
27
+ api_key=os.environ.get("YOUTUBE_API_KEY") or None,
28
+ token_file=_path_env("YOUTUBE_TOKEN_FILE", config_dir / "token.json"),
29
+ client_secrets_file=_path_env("YOUTUBE_CLIENT_SECRETS", config_dir / "client_secrets.json"),
30
+ config_file=_path_env("YOUTUBE_CONFIG_FILE", config_dir / "config.toml"),
31
+ )
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class DeleteProtection:
36
+ """Videos past either limit can't be deleted through this toolkit."""
37
+
38
+ max_views: int = 100
39
+ max_public_age_days: int = 7
40
+
41
+ @classmethod
42
+ def load(cls, config_file: Path) -> Self:
43
+ """The [delete_protection] table of the config file; defaults when absent. Bad values are an error."""
44
+ if not config_file.exists():
45
+ return cls()
46
+ try:
47
+ table = tomllib.loads(config_file.read_text()).get("delete_protection", {})
48
+ limits = cls(**table)
49
+ except (tomllib.TOMLDecodeError, TypeError) as e:
50
+ raise InvalidRequest(f"Invalid {config_file}: {e}") from e
51
+ for name, value in vars(limits).items():
52
+ if type(value) is not int or value < 0:
53
+ raise InvalidRequest(f"Invalid {config_file}: delete_protection.{name} must be a whole number >= 0.")
54
+ return limits
55
+
56
+
57
+ def _path_env(name: str, default: Path) -> Path:
58
+ value = os.environ.get(name)
59
+ return Path(value).expanduser() if value else default
@@ -0,0 +1,118 @@
1
+ """Domain errors. Adapters map these to MCP tool errors and CLI exit codes."""
2
+
3
+ import json
4
+ from typing import Any, ClassVar
5
+
6
+ from googleapiclient.errors import HttpError
7
+
8
+
9
+ class YouTubeError(Exception):
10
+ """Base class. `kind` is a stable machine-readable name; `exit_code` is the CLI status.
11
+
12
+ `details` is optional structured context the caller can act on.
13
+ """
14
+
15
+ kind: ClassVar[str] = "error"
16
+ exit_code: ClassVar[int] = 1
17
+
18
+ def __init__(self, message: str, *, details: dict[str, Any] | None = None):
19
+ super().__init__(message)
20
+ self.details = details or {}
21
+
22
+
23
+ class InvalidRequest(YouTubeError):
24
+ kind = "invalid_request"
25
+ exit_code = 2
26
+
27
+
28
+ class NotFound(YouTubeError):
29
+ kind = "not_found"
30
+ exit_code = 3
31
+
32
+
33
+ class NotAuthorized(YouTubeError):
34
+ kind = "not_authorized"
35
+ exit_code = 4
36
+
37
+
38
+ class QuotaExceeded(YouTubeError):
39
+ kind = "quota_exceeded"
40
+ exit_code = 5
41
+
42
+
43
+ class Forbidden(YouTubeError):
44
+ """Authorized, but YouTube refuses the action (e.g. comments disabled)."""
45
+
46
+ kind = "forbidden"
47
+ exit_code = 6
48
+
49
+
50
+ class NetworkError(YouTubeError):
51
+ """YouTube could not be reached. Safe to retry."""
52
+
53
+ kind = "network"
54
+ exit_code = 7
55
+
56
+
57
+ class ConfirmationRequired(YouTubeError):
58
+ """A guarded delete needs `confirm`; `details` describe the target and the value to pass."""
59
+
60
+ kind = "confirmation_required"
61
+ exit_code = 8
62
+
63
+
64
+ class Protected(YouTubeError):
65
+ """Refused by the delete protection limits. No flag overrides them; only the config file does."""
66
+
67
+ kind = "protected"
68
+ exit_code = 9
69
+
70
+
71
+ class TimedOut(YouTubeError):
72
+ """Waiting for YouTube took longer than allowed. The work may still finish."""
73
+
74
+ kind = "timeout"
75
+ exit_code = 10
76
+
77
+
78
+ LOGIN_HINT = "Run `ytk auth login` to authorize your YouTube account."
79
+ API_KEY_HINT = "Check YOUTUBE_API_KEY, or unset it to use your OAuth login for public data too."
80
+
81
+ _QUOTA_REASONS = {"quotaExceeded", "dailyLimitExceeded"}
82
+ _AUTH_REASONS = {"authError", "insufficientPermissions"}
83
+
84
+
85
+ def from_http_error(error: HttpError) -> YouTubeError:
86
+ """Translate a googleapiclient HttpError into the matching domain error."""
87
+ status = error.resp.status
88
+ reason = _first_reason(error.content)
89
+ message = error.reason or f"HTTP {status}"
90
+
91
+ if reason in _QUOTA_REASONS:
92
+ return QuotaExceeded(f"{message} Daily quota resets at midnight Pacific Time.")
93
+ if status == 429:
94
+ return QuotaExceeded(f"{message} Too many requests; retry in a minute.")
95
+ # A bad key (400) and a missing scope (403) only show their specific reason in the error details.
96
+ if b"API_KEY_INVALID" in error.content:
97
+ return NotAuthorized(f"{message} {API_KEY_HINT}")
98
+ if b"ACCESS_TOKEN_SCOPE_INSUFFICIENT" in error.content:
99
+ return NotAuthorized(f"{message} {LOGIN_HINT}")
100
+ if reason == "youtubeSignupRequired":
101
+ return Forbidden(f"{message} The authorized Google account needs a YouTube channel.")
102
+ if status == 401 or reason in _AUTH_REASONS:
103
+ return NotAuthorized(f"{message} {LOGIN_HINT}")
104
+ if status == 403:
105
+ return Forbidden(message)
106
+ if status == 404:
107
+ return NotFound(message)
108
+ if status == 400:
109
+ return InvalidRequest(message)
110
+ return YouTubeError(f"YouTube API error (HTTP {status}): {message}")
111
+
112
+
113
+ def _first_reason(content: bytes) -> str | None:
114
+ try:
115
+ errors = json.loads(content)["error"]["errors"]
116
+ return errors[0]["reason"]
117
+ except (ValueError, KeyError, IndexError, TypeError):
118
+ return None
@@ -0,0 +1,50 @@
1
+ """Guards for irreversible deletes: a confirmation bound to the target, and damage limits for videos."""
2
+
3
+ from datetime import UTC, datetime
4
+ from typing import Any
5
+
6
+ from .config import DeleteProtection, Settings
7
+ from .errors import ConfirmationRequired, InvalidRequest, Protected
8
+ from .models import Video
9
+
10
+
11
+ def require_confirm(kind: str, target_id: str, confirm: str | None, plan: dict[str, Any]) -> None:
12
+ """Pass only when `confirm` names the target; otherwise describe what would be deleted."""
13
+ if confirm == target_id:
14
+ return
15
+ if confirm is not None:
16
+ raise InvalidRequest(f"confirm must be {target_id}, the ID being deleted; got {confirm}.")
17
+ raise ConfirmationRequired(
18
+ f"Deleting {kind} {target_id} can't be undone. Show the user what will be deleted, "
19
+ f"and only after they approve, repeat the request with confirm set to {target_id}.",
20
+ details={"kind": kind, **plan, "confirm": target_id},
21
+ )
22
+
23
+
24
+ def check_protected(video: Video) -> None:
25
+ """Refuse videos past the configured limits, or whose view count we can't see."""
26
+ settings = Settings.from_env()
27
+ limits = DeleteProtection.load(settings.config_file)
28
+ views = video.stats.views if video.stats else None
29
+ reasons = []
30
+ if views is None:
31
+ reasons.append("its view count is hidden")
32
+ elif views > limits.max_views:
33
+ reasons.append(f"it has {views} views (limit {limits.max_views})")
34
+ if video.privacy == "public":
35
+ # YouTube only exposes the upload time, so a video made public later counts from its upload.
36
+ age = (datetime.now(UTC) - video.published_at).days if video.published_at else None
37
+ if age is None or age > limits.max_public_age_days:
38
+ uploaded = "at an unknown time" if age is None else f"{age} days ago"
39
+ reasons.append(f"it is public and was uploaded {uploaded} (limit {limits.max_public_age_days} days)")
40
+ if reasons:
41
+ raise Protected(
42
+ f"Video {video.id} is protected from deletion: {'; '.join(reasons)}. Delete it in YouTube Studio, "
43
+ f"or raise the limits under [delete_protection] in {settings.config_file}.",
44
+ details={
45
+ "studio_url": f"https://studio.youtube.com/video/{video.id}/edit",
46
+ "config_file": str(settings.config_file),
47
+ "max_views": limits.max_views,
48
+ "max_public_age_days": limits.max_public_age_days,
49
+ },
50
+ )