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.
- youtube_kit/__init__.py +0 -0
- youtube_kit/cli.py +217 -0
- youtube_kit/core/__init__.py +36 -0
- youtube_kit/core/auth.py +103 -0
- youtube_kit/core/client.py +62 -0
- youtube_kit/core/config.py +59 -0
- youtube_kit/core/errors.py +118 -0
- youtube_kit/core/guard.py +50 -0
- youtube_kit/core/models.py +228 -0
- youtube_kit/core/operation.py +102 -0
- youtube_kit/core/ops/__init__.py +3 -0
- youtube_kit/core/ops/_common.py +83 -0
- youtube_kit/core/ops/analytics.py +107 -0
- youtube_kit/core/ops/branding.py +276 -0
- youtube_kit/core/ops/channels.py +112 -0
- youtube_kit/core/ops/comments.py +205 -0
- youtube_kit/core/ops/playlists.py +282 -0
- youtube_kit/core/ops/search.py +72 -0
- youtube_kit/core/ops/subscriptions.py +77 -0
- youtube_kit/core/ops/videos.py +332 -0
- youtube_kit/core/pagination.py +41 -0
- youtube_kit/core/parse.py +144 -0
- youtube_kit/core/progress.py +29 -0
- youtube_kit/mcp_server.py +84 -0
- youtube_kit/py.typed +0 -0
- youtube_kit-0.2.0.dist-info/METADATA +174 -0
- youtube_kit-0.2.0.dist-info/RECORD +30 -0
- youtube_kit-0.2.0.dist-info/WHEEL +4 -0
- youtube_kit-0.2.0.dist-info/entry_points.txt +3 -0
- youtube_kit-0.2.0.dist-info/licenses/LICENSE +21 -0
youtube_kit/__init__.py
ADDED
|
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
|
+
]
|
youtube_kit/core/auth.py
ADDED
|
@@ -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
|
+
)
|