flashdata-youtube-channel 0.1.0__tar.gz

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.
@@ -0,0 +1,9 @@
1
+ .venv/
2
+ __pycache__/
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ packages/
6
+ dist/
7
+ evidence/
8
+
9
+ publish-dist/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 FlashData contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.5
2
+ Name: flashdata-youtube-channel
3
+ Version: 0.1.0
4
+ Summary: Snapshot channel metadata and recent videos with the FlashData JSON CLI
5
+ Project-URL: Homepage, https://flashdata.dev/docs/sources/youtube_channel
6
+ Project-URL: Documentation, https://flashdata.dev/docs/sources/youtube_channel
7
+ Project-URL: Pricing, https://flashdata.dev/pricing
8
+ Project-URL: Support, https://flashdata.dev
9
+ Project-URL: Source, https://github.com/rocosen/flashdata-mcp/tree/main/packages/python-cli
10
+ Author-email: FlashData <support@flashdata.dev>
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: cli,flashdata,json,uvx,youtube-channel
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Programming Language :: Python :: 3
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: httpx<1,>=0.28.1
19
+ Requires-Dist: jsonschema<5,>=4.23
20
+ Requires-Dist: pydantic<3,>=2.7.4
21
+ Description-Content-Type: text/markdown
22
+
23
+ # YouTube Channel CLI — FlashData
24
+
25
+ Snapshot channel metadata and recent videos. This command calls the `youtube_channel` API and returns JSON for your
26
+ shell script, data pipeline or agent tool runner. It does not invoke a language model.
27
+
28
+ A recent-video snapshot is not the complete channel archive.
29
+
30
+ ## Install and run
31
+
32
+ Requires Python 3.10 or newer. [uv installation](https://docs.astral.sh/uv/getting-started/installation/)
33
+ provides `uvx`, which runs this package in an isolated environment.
34
+
35
+ 1. [Create a FlashData account](https://flashdata.dev/signup), obtain an API Key and fund Credits.
36
+ 2. Set `FLASHDATA_API_KEY` using your shell or secret manager. The CLI does not accept a key flag
37
+ or persist credentials. [Authentication and source documentation](https://flashdata.dev/docs/sources/youtube_channel).
38
+ 3. Run this example after reviewing the input and cost:
39
+
40
+ ```sh
41
+ uvx flashdata-youtube-channel@0.1.0 --handle @GoogleDevelopers --options-json '{"video_limit": 5}' --output result.json
42
+ ```
43
+
44
+ `--output` creates a new file with owner-only permissions where supported and refuses existing
45
+ files before making a paid request. Without it, the full JSON response goes to stdout. Errors
46
+ go to stderr. Exit 0 means the response was exported, 2 means local input/configuration failure,
47
+ and 1 means request or output failure. For downloads, exit 0 can describe an unfinished job.
48
+
49
+ For repeated use, install `uv tool install flashdata-youtube-channel==0.1.0`; the command name is `flashdata-youtube-channel`.
50
+ Alternatively use `python -m pip install flashdata-youtube-channel==0.1.0` in your virtual environment.
51
+
52
+ ## Inputs and output
53
+
54
+ Use `--handle` for the primary input. Additional options use
55
+ `--options-json` as a JSON object, with strict field/type/range validation. Source is fixed by
56
+ this package. Google count and locale options remain omitted unless supplied.
57
+
58
+ | Option | Meaning |
59
+ | --- | --- |
60
+ | `video_limit` | Source-specific option; see --schema. Default: `20`. |
61
+
62
+ Run `uvx flashdata-youtube-channel@0.1.0 --schema` to inspect all fields, nested filters, allowed values and
63
+ defaults without a Key or an API call. `--help` and `--version` are also local-only.
64
+
65
+ The JSON output preserves the API result envelope and optional fields: results[]: channelId, channelHandle, name, channelUrl, statistics, videos; usage.credits.
66
+ Missing fields remain missing; empty results remain empty. No article text, translated captions,
67
+ model conclusions, rights grants or keyword volumes are invented. Exact source contracts are
68
+ documented [here](https://flashdata.dev/docs/sources/youtube_channel).
69
+
70
+ ## Fees and failure handling
71
+
72
+ This MIT-licensed CLI is free to install. FlashData API requests consume paid Credits;
73
+ [current pricing](https://flashdata.dev/pricing) applies. Extended YouTube Search has a separate
74
+ rate. Media delivery is billed by delivered volume, and your cloud-storage costs are separate.
75
+ There is no automatic pagination, retry, polling, scheduled execution or model call.
76
+
77
+ Each HTTP request has a 120-second network timeout (15-second connect timeout) and a 20-MiB response
78
+ limit. A timeout or failure may occur after a paid request has been accepted. Check Jobs in
79
+ the [FlashData console](https://flashdata.dev) before resubmitting. HTTP 401 means check the Key;
80
+ 402 means insufficient Credits; 429 means rate/quota limit. A partial output file is retained
81
+ on failure. Do not retry a paid query just because output handling failed.
82
+
83
+ Input values and your API Key are sent to FlashData to perform the request. A completed
84
+ download-result read may fetch a signed JSON object from the result origin returned by
85
+ FlashData; the API Key is never sent to that object URL. Only process data appropriate for
86
+ your output destination. [Privacy policy](https://flashdata.dev/legal/privacy).
87
+
88
+ Source and MIT license are included in the source distribution.
89
+
90
+ [Source documentation](https://flashdata.dev/docs/sources/youtube_channel) ·
91
+ [Pricing](https://flashdata.dev/pricing) · [Sign up](https://flashdata.dev/signup)
@@ -0,0 +1,69 @@
1
+ # YouTube Channel CLI — FlashData
2
+
3
+ Snapshot channel metadata and recent videos. This command calls the `youtube_channel` API and returns JSON for your
4
+ shell script, data pipeline or agent tool runner. It does not invoke a language model.
5
+
6
+ A recent-video snapshot is not the complete channel archive.
7
+
8
+ ## Install and run
9
+
10
+ Requires Python 3.10 or newer. [uv installation](https://docs.astral.sh/uv/getting-started/installation/)
11
+ provides `uvx`, which runs this package in an isolated environment.
12
+
13
+ 1. [Create a FlashData account](https://flashdata.dev/signup), obtain an API Key and fund Credits.
14
+ 2. Set `FLASHDATA_API_KEY` using your shell or secret manager. The CLI does not accept a key flag
15
+ or persist credentials. [Authentication and source documentation](https://flashdata.dev/docs/sources/youtube_channel).
16
+ 3. Run this example after reviewing the input and cost:
17
+
18
+ ```sh
19
+ uvx flashdata-youtube-channel@0.1.0 --handle @GoogleDevelopers --options-json '{"video_limit": 5}' --output result.json
20
+ ```
21
+
22
+ `--output` creates a new file with owner-only permissions where supported and refuses existing
23
+ files before making a paid request. Without it, the full JSON response goes to stdout. Errors
24
+ go to stderr. Exit 0 means the response was exported, 2 means local input/configuration failure,
25
+ and 1 means request or output failure. For downloads, exit 0 can describe an unfinished job.
26
+
27
+ For repeated use, install `uv tool install flashdata-youtube-channel==0.1.0`; the command name is `flashdata-youtube-channel`.
28
+ Alternatively use `python -m pip install flashdata-youtube-channel==0.1.0` in your virtual environment.
29
+
30
+ ## Inputs and output
31
+
32
+ Use `--handle` for the primary input. Additional options use
33
+ `--options-json` as a JSON object, with strict field/type/range validation. Source is fixed by
34
+ this package. Google count and locale options remain omitted unless supplied.
35
+
36
+ | Option | Meaning |
37
+ | --- | --- |
38
+ | `video_limit` | Source-specific option; see --schema. Default: `20`. |
39
+
40
+ Run `uvx flashdata-youtube-channel@0.1.0 --schema` to inspect all fields, nested filters, allowed values and
41
+ defaults without a Key or an API call. `--help` and `--version` are also local-only.
42
+
43
+ The JSON output preserves the API result envelope and optional fields: results[]: channelId, channelHandle, name, channelUrl, statistics, videos; usage.credits.
44
+ Missing fields remain missing; empty results remain empty. No article text, translated captions,
45
+ model conclusions, rights grants or keyword volumes are invented. Exact source contracts are
46
+ documented [here](https://flashdata.dev/docs/sources/youtube_channel).
47
+
48
+ ## Fees and failure handling
49
+
50
+ This MIT-licensed CLI is free to install. FlashData API requests consume paid Credits;
51
+ [current pricing](https://flashdata.dev/pricing) applies. Extended YouTube Search has a separate
52
+ rate. Media delivery is billed by delivered volume, and your cloud-storage costs are separate.
53
+ There is no automatic pagination, retry, polling, scheduled execution or model call.
54
+
55
+ Each HTTP request has a 120-second network timeout (15-second connect timeout) and a 20-MiB response
56
+ limit. A timeout or failure may occur after a paid request has been accepted. Check Jobs in
57
+ the [FlashData console](https://flashdata.dev) before resubmitting. HTTP 401 means check the Key;
58
+ 402 means insufficient Credits; 429 means rate/quota limit. A partial output file is retained
59
+ on failure. Do not retry a paid query just because output handling failed.
60
+
61
+ Input values and your API Key are sent to FlashData to perform the request. A completed
62
+ download-result read may fetch a signed JSON object from the result origin returned by
63
+ FlashData; the API Key is never sent to that object URL. Only process data appropriate for
64
+ your output destination. [Privacy policy](https://flashdata.dev/legal/privacy).
65
+
66
+ Source and MIT license are included in the source distribution.
67
+
68
+ [Source documentation](https://flashdata.dev/docs/sources/youtube_channel) ·
69
+ [Pricing](https://flashdata.dev/pricing) · [Sign up](https://flashdata.dev/signup)
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,2 @@
1
+ SOURCE = 'youtube_channel'
2
+ COMMAND = 'flashdata-youtube-channel'
@@ -0,0 +1,152 @@
1
+ """Bounded, single-attempt requests for the standalone command-line tools."""
2
+
3
+ from urllib.parse import urlsplit
4
+ from uuid import uuid4
5
+
6
+ import httpx
7
+
8
+ QUERY_URL = "https://data.flashdata.dev/v1/queries/realtime"
9
+ ASYNC_URL = "https://data.flashdata.dev/v1/queries"
10
+ JOBS_URL = "https://api.flashdata.dev/v2/jobs"
11
+ AUTH_URL = "https://api.flashdata.dev/v2/api-access"
12
+ MAX_RESPONSE_BYTES = 20 * 1024 * 1024
13
+ UNKNOWN_OUTCOME = (
14
+ "The request may already have been accepted and charged. "
15
+ "Check Jobs in the FlashData console before resubmitting. No automatic retry was made."
16
+ )
17
+ READ_ERROR = "Could not complete this read. Retry the same job read; do not submit a new job."
18
+
19
+
20
+ class FlashDataError(Exception):
21
+ """An error safe to display without raw responses or credentials."""
22
+
23
+
24
+ class Client:
25
+ def __init__(self, api_key, *, transport=None):
26
+ self.api_key = api_key
27
+ self.transport = transport
28
+
29
+ def _request(self, method, url, payload=None, *, authenticated=True):
30
+ headers = {"Accept": "application/json", "User-Agent": "flashdata-cli/0.1.0"}
31
+ if authenticated:
32
+ headers.update({"X-API-Key": self.api_key, "X-Request-Id": str(uuid4())})
33
+ try:
34
+ with (
35
+ httpx.Client(
36
+ timeout=httpx.Timeout(120.0, connect=15.0),
37
+ follow_redirects=False,
38
+ trust_env=False,
39
+ transport=self.transport,
40
+ ) as client,
41
+ client.stream(method, url, json=payload, headers=headers) as response,
42
+ ):
43
+ body = bytearray()
44
+ for chunk in response.iter_bytes():
45
+ if len(body) + len(chunk) > MAX_RESPONSE_BYTES:
46
+ raise FlashDataError(
47
+ "Response exceeds 20 MiB. Retrieve it from Jobs; do not resubmit."
48
+ )
49
+ body.extend(chunk)
50
+ # iter_bytes already decodes content encodings; do not decode twice.
51
+ return httpx.Response(
52
+ response.status_code,
53
+ content=bytes(body),
54
+ headers={"location": response.headers.get("location", "")},
55
+ )
56
+ except httpx.HTTPError:
57
+ raise FlashDataError(UNKNOWN_OUTCOME if method == "POST" else READ_ERROR) from None
58
+
59
+ @staticmethod
60
+ def _json(response, *, submission=False):
61
+ message = UNKNOWN_OUTCOME if submission else READ_ERROR
62
+ errors = {
63
+ 401: "API key rejected. Check FLASHDATA_API_KEY.",
64
+ 402: "Insufficient FlashData Credits. Fund your account before a new request.",
65
+ 403: "This account or key cannot access the requested source.",
66
+ 429: "FlashData rate or quota limit reached. Wait before a new request.",
67
+ }
68
+ if response.status_code != 200:
69
+ raise FlashDataError(
70
+ f"{errors.get(response.status_code, message)} HTTP {response.status_code}."
71
+ )
72
+ try:
73
+ data = response.json()
74
+ except ValueError:
75
+ raise FlashDataError(message) from None
76
+ if not isinstance(data, dict):
77
+ raise FlashDataError(message)
78
+ return data
79
+
80
+ def query(self, payload):
81
+ data = self._json(self._request("POST", QUERY_URL, payload), submission=True)
82
+ if data.get("status") not in (None, "done", "completed") or not isinstance(
83
+ data.get("results"), list
84
+ ):
85
+ raise FlashDataError(UNKNOWN_OUTCOME)
86
+ job = data.get("job")
87
+ if isinstance(job, dict) and job.get("status") == "failed":
88
+ raise FlashDataError("The job failed. Inspect it in Jobs before a new request.")
89
+ return data
90
+
91
+ def submit(self, payload):
92
+ from ._inputs import DownloadJobInput
93
+
94
+ data = self._json(self._request("POST", ASYNC_URL, payload), submission=True)
95
+ try:
96
+ DownloadJobInput(job_id=data.get("id"))
97
+ except ValueError:
98
+ raise FlashDataError(UNKNOWN_OUTCOME) from None
99
+ if data.get("status") not in ("pending", "processing"):
100
+ raise FlashDataError(UNKNOWN_OUTCOME)
101
+ return {k: data[k] for k in ("id", "status", "source", "created_at") if k in data}
102
+
103
+ def status(self, job_id):
104
+ from ._inputs import DownloadJobInput
105
+
106
+ DownloadJobInput(job_id=job_id)
107
+ data = self._json(self._request("GET", f"{JOBS_URL}/{job_id}"))
108
+ job = data.get("job")
109
+ if not isinstance(job, dict) or job.get("source") != "youtube_download":
110
+ raise FlashDataError("This reader only accepts YouTube download jobs.")
111
+ if job.get("id") != job_id:
112
+ raise FlashDataError("Job receipt does not match the requested ID.")
113
+ # Job params can contain storage credentials. Export lifecycle fields only.
114
+ return {
115
+ "job": {
116
+ k: job[k]
117
+ for k in ("id", "source", "status", "created_at", "updated_at", "completed_at")
118
+ if k in job
119
+ }
120
+ }
121
+
122
+ def result(self, job_id):
123
+ status = self.status(job_id)
124
+ if status["job"].get("status") != "completed":
125
+ return status
126
+ identity = self._json(self._request("GET", AUTH_URL))
127
+ link = self._request("GET", f"{JOBS_URL}/{job_id}/results")
128
+ try:
129
+ origin = identity.get("result_origin")
130
+ trusted = urlsplit(origin) if isinstance(origin, str) else None
131
+ target = urlsplit(link.headers.get("location", ""))
132
+ if (
133
+ link.status_code != 302
134
+ or not trusted
135
+ or trusted.scheme != "https"
136
+ or not trusted.hostname
137
+ or trusted.username
138
+ or trusted.password
139
+ or trusted.path not in ("", "/")
140
+ or trusted.query
141
+ or trusted.fragment
142
+ or target.scheme != "https"
143
+ or target.username
144
+ or target.password
145
+ or target.fragment
146
+ or (target.hostname, target.port or 443) != (trusted.hostname, trusted.port or 443)
147
+ ):
148
+ raise ValueError()
149
+ except ValueError:
150
+ raise FlashDataError("Result URL does not match trusted FlashData storage.") from None
151
+ output = self._json(self._request("GET", target.geturl(), authenticated=False))
152
+ return {**status, "output": output}
@@ -0,0 +1,213 @@
1
+ """Validated tool arguments. Credentials are never part of these schemas."""
2
+
3
+ import re
4
+ from typing import Annotated, Literal
5
+ from urllib.parse import parse_qs, urlsplit
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
8
+
9
+ Query = Annotated[str, Field(min_length=1, max_length=2000)]
10
+ Language = Annotated[str, Field(pattern=r"^[A-Za-z0-9-]{1,35}$")]
11
+
12
+
13
+ class _Input(BaseModel):
14
+ model_config = ConfigDict(extra="forbid", strict=True, str_strip_whitespace=True)
15
+
16
+
17
+ class GoogleInput(_Input):
18
+ """Google source parameters; omitted settings use provider behavior."""
19
+
20
+ query: Query = Field(description="Search query, between 1 and 2,000 characters.")
21
+ num: int | None = Field(default=None, ge=1, le=100, description="Requested result count.")
22
+ page: int | None = Field(default=None, ge=1, le=100, description="Search result page.")
23
+ autocorrect: bool | None = Field(default=None, description="Enable spelling autocorrection.")
24
+ gl: Annotated[str, Field(pattern=r"^[A-Za-z]{2}$")] | None = Field(
25
+ default=None, description="Two-letter country code, for example us."
26
+ )
27
+ hl: Language | None = Field(
28
+ default=None, description="Interface language code, for example en."
29
+ )
30
+ location: Annotated[str, Field(min_length=1, max_length=200)] | None = Field(
31
+ default=None, description="Optional geographic search location."
32
+ )
33
+ tbs: Annotated[str, Field(min_length=1, max_length=200)] | None = Field(
34
+ default=None, description="Optional Google time filter, for example qdr:w."
35
+ )
36
+
37
+
38
+ class GoogleSearchInput(GoogleInput):
39
+ """Preserve the original Google Search tool defaults."""
40
+
41
+ num: int = Field(default=10, ge=1, le=100)
42
+ page: int = Field(default=1, ge=1, le=100)
43
+ autocorrect: bool = True
44
+
45
+
46
+ class YouTubeSearchFilters(_Input):
47
+ """Optional YouTube search filters, including feature flags."""
48
+
49
+ duration: Literal["<4", "4-20", ">20"] | None = None
50
+ sort_by: Literal["rating", "relevance", "view_count", "upload_date"] | None = None
51
+ type: Literal["video", "channel", "playlist", "movie"] | None = None
52
+ upload_date: Literal["today", "last_hour", "this_week", "this_month", "this_year"] | None = None
53
+ video_360: bool | None = Field(default=None, alias="360")
54
+ video_3d: bool | None = Field(default=None, alias="3d")
55
+ video_4k: bool | None = Field(default=None, alias="4k")
56
+ creative_commons: bool | None = None
57
+ hd: bool | None = None
58
+ hdr: bool | None = None
59
+ live: bool | None = None
60
+ location: bool | None = None
61
+ purchased: bool | None = None
62
+ subtitles: bool | None = None
63
+ vr180: bool | None = None
64
+
65
+
66
+ class YouTubeSearchInput(_Input):
67
+ """YouTube video search parameters."""
68
+
69
+ query: Query = Field(description="YouTube search query, between 1 and 2,000 characters.")
70
+ max_results: int = Field(default=10, ge=1, le=20, description="Requested number of videos.")
71
+ filters: YouTubeSearchFilters | None = None
72
+
73
+
74
+ class YouTubeExtendedSearchInput(YouTubeSearchInput):
75
+ """Larger searches have a separate source and rate; count is not guaranteed."""
76
+
77
+ max_results: int = Field(
78
+ default=20, ge=1, le=700, description="Requested result limit, up to 700."
79
+ )
80
+
81
+
82
+ class _VideoInput(_Input):
83
+ video_id: str = Field(description="11-character YouTube video ID or a YouTube video URL.")
84
+
85
+ @field_validator("video_id")
86
+ @classmethod
87
+ def normalize_video_id(cls, value: str) -> str:
88
+ if re.fullmatch(r"[A-Za-z0-9_-]{11}", value):
89
+ return value
90
+ try:
91
+ url = urlsplit(value)
92
+ parts = url.path.split("/")
93
+ candidate = ""
94
+ if url.scheme in ("http", "https") and not url.username and not url.password:
95
+ if url.hostname == "youtu.be" and (
96
+ len(parts) == 2 or (len(parts) == 3 and not parts[2])
97
+ ):
98
+ candidate = parts[1]
99
+ elif url.hostname in (
100
+ "youtube.com",
101
+ "www.youtube.com",
102
+ "m.youtube.com",
103
+ "music.youtube.com",
104
+ ):
105
+ if url.path == "/watch":
106
+ candidate = parse_qs(url.query).get("v", [""])[0]
107
+ elif (len(parts) == 3 or (len(parts) == 4 and not parts[3])) and parts[1] in (
108
+ "shorts",
109
+ "embed",
110
+ "live",
111
+ ):
112
+ candidate = parts[2]
113
+ if re.fullmatch(r"[A-Za-z0-9_-]{11}", candidate):
114
+ return candidate
115
+ except ValueError:
116
+ pass
117
+ raise ValueError("Enter an 11-character YouTube ID or a youtube.com / youtu.be video URL.")
118
+
119
+
120
+ class YouTubeMetadataInput(_VideoInput):
121
+ """YouTube metadata parameters."""
122
+
123
+ include_chapters: bool = Field(default=True, description="Include available video chapters.")
124
+ include_formats: bool = Field(default=True, description="Include available format metadata.")
125
+
126
+
127
+ class YouTubeTranscriptInput(_VideoInput):
128
+ """YouTube transcript parameters; no implicit language or caption fallback."""
129
+
130
+ language: Language = Field(default="en", description="Requested caption language code.")
131
+ auto: bool = Field(
132
+ default=True,
133
+ description="True requests automatic captions; false requests manually authored captions.",
134
+ )
135
+
136
+
137
+ class YouTubeCaptionsInput(YouTubeTranscriptInput):
138
+ """Select uploader captions by default, without fallback."""
139
+
140
+ auto: bool = Field(default=False, description="False selects manual; true automatic captions.")
141
+
142
+
143
+ class YouTubeTrainabilityInput(_VideoInput):
144
+ """Inspect license labels; this does not establish training permission."""
145
+
146
+
147
+ class YouTubeChannelInput(_Input):
148
+ handle: Annotated[str, Field(min_length=1, max_length=100)] = Field(
149
+ description="YouTube channel handle, for example @jawed."
150
+ )
151
+ video_limit: int = Field(default=20, ge=1, le=100)
152
+
153
+
154
+ class YouTubeSuggestionsInput(_Input):
155
+ query: Query = Field(description="YouTube search prefix.")
156
+ language: Language = "en"
157
+ region: Annotated[str, Field(pattern=r"^[A-Za-z]{2}$")] = "US"
158
+
159
+
160
+ class YouTubeDownloadInput(_VideoInput):
161
+ """Media options only. Storage credentials are injected by the application."""
162
+
163
+ download_type: Literal["audio_video", "video", "audio"] = "audio_video"
164
+ video_quality: (
165
+ Annotated[
166
+ str,
167
+ Field(pattern=r"^(best|worst|(?:<=|==|>=)(?:144|360|480|720|1080|1440|2160|4320))$"),
168
+ ]
169
+ | None
170
+ ) = None
171
+ fps: Annotated[str, Field(pattern=r"^(?:>=|<=|==)(?:[1-9]\d?|1\d\d|2[0-3]\d|240)$")] | None = (
172
+ None
173
+ )
174
+ audio_language: Literal["default", "original"] | None = None
175
+ audio_quality: Literal["best", "320", "256", "192", "128"] | None = None
176
+ yt_dlp_format: Annotated[str, Field(min_length=1, max_length=1024)] | None = None
177
+ upload_metadata: bool = False
178
+ output_format: Literal["mp4", "webm", "mkv", "m4a", "mp3", "opus"] | None = None
179
+ start_at: Annotated[str, Field(pattern=r"^\d{1,2}:[0-5]\d:[0-5]\d$")] | None = None
180
+ end_at: Annotated[str, Field(pattern=r"^\d{1,2}:[0-5]\d:[0-5]\d$")] | None = None
181
+
182
+ @model_validator(mode="after")
183
+ def validate_options(self):
184
+ audio = self.download_type == "audio"
185
+ formats = ("m4a", "mp3", "opus") if audio else ("mp4", "webm", "mkv")
186
+ if self.output_format is not None and self.output_format not in formats:
187
+ raise ValueError("output_format is incompatible with download_type.")
188
+ if self.yt_dlp_format and any(ord(c) < 32 or ord(c) == 127 for c in self.yt_dlp_format):
189
+ raise ValueError("yt_dlp_format must not contain control characters.")
190
+ if not self.yt_dlp_format:
191
+ if audio and (self.video_quality is not None or self.fps is not None):
192
+ raise ValueError("video_quality and fps do not apply to audio downloads.")
193
+ if self.download_type == "video" and self.audio_language is not None:
194
+ raise ValueError("audio_language does not apply to video-only downloads.")
195
+ if not audio and self.audio_quality is not None:
196
+ raise ValueError("audio_quality applies only to audio downloads.")
197
+ times = {}
198
+ for field in ("start_at", "end_at"):
199
+ value = getattr(self, field)
200
+ if value is not None:
201
+ h, m, s = map(int, value.split(":"))
202
+ times[field] = h * 3600 + m * 60 + s
203
+ if times[field] > 43200:
204
+ raise ValueError("Clip timestamps must not exceed 12:00:00.")
205
+ if "end_at" in times and times["end_at"] <= times.get("start_at", 0):
206
+ raise ValueError("end_at must be later than start_at.")
207
+ return self
208
+
209
+
210
+ class DownloadJobInput(_Input):
211
+ job_id: Annotated[str, Field(pattern=r"^job_[A-Za-z0-9-]{1,100}$")] = Field(
212
+ description="Job ID from a YouTube download submission."
213
+ )
@@ -0,0 +1,108 @@
1
+ """Customer storage validation. Never include credentials in errors."""
2
+
3
+ import json
4
+ import re
5
+ from pathlib import Path
6
+ from urllib.parse import parse_qs
7
+
8
+ from jsonschema import Draft7Validator
9
+
10
+ from ._client import FlashDataError
11
+
12
+ STORAGE_SCHEMA = json.loads(Path(__file__).with_name("storage.schema.json").read_text())
13
+
14
+
15
+ def storage_config(raw):
16
+ try:
17
+ storage = json.loads(raw)
18
+ except (ValueError, TypeError):
19
+ raise FlashDataError("Download storage must contain a JSON object.") from None
20
+ if not Draft7Validator(STORAGE_SCHEMA).is_valid(storage):
21
+ raise FlashDataError("Invalid download storage credential. Check its fields and types.")
22
+ kind = storage["storage_type"]
23
+ bucket = storage["storage_url"].strip()
24
+ if not re.fullmatch(r"[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]", bucket) or ".." in bucket:
25
+ raise FlashDataError(
26
+ "storage_url must be a bucket/container name; put folders in object_prefix."
27
+ )
28
+ if kind == "s3" and (
29
+ re.fullmatch(r"\d{1,3}(?:\.\d{1,3}){3}", bucket) or ".-" in bucket or "-." in bucket
30
+ ):
31
+ raise FlashDataError("storage_url must be a valid AWS S3 bucket name.")
32
+ storage["storage_url"] = bucket
33
+ provider_fields = (
34
+ ["service_account"]
35
+ if kind == "gcs"
36
+ else ["account_name", "sas_token"]
37
+ if kind == "azure"
38
+ else ["access_key", "secret_key", "region"]
39
+ if kind == "cos"
40
+ else ["access_key", "secret_key", "region", "endpoint_url"]
41
+ )
42
+ if set(storage) - {"storage_type", "storage_url", "object_prefix", *provider_fields}:
43
+ raise FlashDataError("The storage credential contains fields for a different provider.")
44
+ if "object_prefix" in storage:
45
+ prefix = storage["object_prefix"].strip().removesuffix("/")
46
+ if (
47
+ not prefix
48
+ or len(prefix) > 512
49
+ or any(
50
+ part in (".", "..") or not re.fullmatch(r"[A-Za-z0-9._-]+", part)
51
+ for part in prefix.split("/")
52
+ )
53
+ ):
54
+ raise FlashDataError("object_prefix must use safe folder names, up to 512 characters.")
55
+ storage["object_prefix"] = prefix
56
+ if kind == "gcs":
57
+ account = storage.get("service_account", {})
58
+ if (
59
+ not all(account.get(k) for k in ("project_id", "client_email", "private_key"))
60
+ or not re.fullmatch(
61
+ r"[^@\s]+@[^@\s]+\.gserviceaccount\.com", account.get("client_email", "")
62
+ )
63
+ or "BEGIN PRIVATE KEY" not in account.get("private_key", "")
64
+ ):
65
+ raise FlashDataError(
66
+ "GCS requires project_id, service-account client_email and a PEM private_key."
67
+ )
68
+ storage["service_account"] = {
69
+ key: account[key] for key in ("project_id", "client_email", "private_key")
70
+ }
71
+ elif kind == "azure":
72
+ sas = storage.get("sas_token", "").removeprefix("?")
73
+ fields = parse_qs(sas)
74
+ if (
75
+ not storage.get("account_name")
76
+ or not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", bucket)
77
+ or not all(fields.get(k) for k in ("sig", "se", "sv"))
78
+ or "w" not in fields.get("sp", [""])[0]
79
+ or fields.get("sr") != ["c"]
80
+ or "\n" in sas
81
+ or "\r" in sas
82
+ or len(sas) > 4096
83
+ ):
84
+ raise FlashDataError(
85
+ "Azure requires an account name and container SAS with write permission and expiry."
86
+ )
87
+ storage["sas_token"] = sas
88
+ else:
89
+ if not all(storage.get(k, "").strip() for k in ("access_key", "secret_key")):
90
+ raise FlashDataError(
91
+ "Download storage requires access_key and secret_key in the provider credential."
92
+ )
93
+ if kind == "s3_compatible" and not storage.get("endpoint_url"):
94
+ raise FlashDataError("S3-compatible storage requires endpoint_url.")
95
+ region = storage.get("region", "")
96
+ if (
97
+ (kind in ("cos", "bos", "tos") and "." in bucket)
98
+ or (
99
+ kind == "cos"
100
+ and (
101
+ not re.fullmatch(r"ap-[a-z0-9-]+", region) or not re.search(r"-\d{5,}$", bucket)
102
+ )
103
+ )
104
+ or (kind == "bos" and region not in ("bj", "su", "hkg"))
105
+ or (kind == "tos" and not re.fullmatch(r"(?:cn|ap|eu|us)-[a-z0-9-]+", region))
106
+ ):
107
+ raise FlashDataError("Check the storage provider region and bucket name.")
108
+ return storage
@@ -0,0 +1,201 @@
1
+ """CLI entry point shared at build time by fifteen independently named packages."""
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+ from urllib.parse import urlsplit
9
+
10
+ from pydantic import ValidationError
11
+
12
+ from . import __version__
13
+ from ._client import UNKNOWN_OUTCOME, Client, FlashDataError
14
+ from ._inputs import (
15
+ DownloadJobInput,
16
+ GoogleInput,
17
+ YouTubeCaptionsInput,
18
+ YouTubeChannelInput,
19
+ YouTubeDownloadInput,
20
+ YouTubeExtendedSearchInput,
21
+ YouTubeMetadataInput,
22
+ YouTubeSearchInput,
23
+ YouTubeSuggestionsInput,
24
+ YouTubeTrainabilityInput,
25
+ YouTubeTranscriptInput,
26
+ )
27
+ from ._storage import storage_config
28
+
29
+ CONTRACTS = {
30
+ **{
31
+ f"google_{kind}": GoogleInput
32
+ for kind in ("search", "news", "images", "shopping", "videos", "places")
33
+ },
34
+ "youtube_search": YouTubeSearchInput,
35
+ "youtube_search_extended": YouTubeExtendedSearchInput,
36
+ "youtube_metadata": YouTubeMetadataInput,
37
+ "youtube_transcript": YouTubeTranscriptInput,
38
+ "youtube_captions": YouTubeCaptionsInput,
39
+ "youtube_trainability": YouTubeTrainabilityInput,
40
+ "youtube_channel": YouTubeChannelInput,
41
+ "youtube_suggestions": YouTubeSuggestionsInput,
42
+ "youtube_download": YouTubeDownloadInput,
43
+ }
44
+
45
+
46
+ class Parser(argparse.ArgumentParser):
47
+ def error(self, message):
48
+ raise FlashDataError("Invalid command arguments. Use --help for supported options.")
49
+
50
+
51
+ def parser_for(source, command):
52
+ parser = Parser(
53
+ prog=command,
54
+ description=(
55
+ f"Run the {source} FlashData API and export JSON. API calls use paid Credits. "
56
+ "Read FLASHDATA_API_KEY from the environment; never pass credentials as arguments."
57
+ ),
58
+ )
59
+ parser.add_argument("--version", action="version", version=f"{command} {__version__}")
60
+ parser.add_argument(
61
+ "--schema", action="store_true", help="Print the input schema; no API call."
62
+ )
63
+ parser.add_argument(
64
+ "--output", type=Path, help="New JSON file (0600); existing files are refused."
65
+ )
66
+ primary = (
67
+ "handle"
68
+ if source == "youtube_channel"
69
+ else "query"
70
+ if (
71
+ source.startswith("google_")
72
+ or source in ("youtube_search", "youtube_search_extended", "youtube_suggestions")
73
+ )
74
+ else "video-id"
75
+ )
76
+ parser.add_argument("--" + primary, help="Primary query, channel handle, or YouTube ID/URL.")
77
+ parser.add_argument(
78
+ "--options-json", help="Source options as a JSON object, without credentials."
79
+ )
80
+ if source == "youtube_download":
81
+ parser.add_argument("--operation", choices=["status", "result", "submit"], default="status")
82
+ parser.add_argument("--job-id", help="Previously saved job ID for status/result.")
83
+ parser.add_argument(
84
+ "--allow-submit", action="store_true", help="Permit one paid storage write."
85
+ )
86
+ return parser
87
+
88
+
89
+ def payload_for(source, args, environ):
90
+ operation = getattr(args, "operation", "query")
91
+ if operation in ("status", "result"):
92
+ if args.video_id or args.options_json or args.allow_submit:
93
+ raise FlashDataError("Status/result accepts --job-id, without submission inputs.")
94
+ return operation, DownloadJobInput(job_id=args.job_id).model_dump()
95
+ try:
96
+ options = json.loads(args.options_json) if args.options_json is not None else {}
97
+ except ValueError:
98
+ raise FlashDataError("--options-json must be a JSON object.") from None
99
+ if not isinstance(options, dict) or any(
100
+ field in options for field in ("source", "query", "video_id", "handle", "storage")
101
+ ):
102
+ raise FlashDataError(
103
+ "Use dedicated primary inputs; source is fixed and secrets use env vars."
104
+ )
105
+ values = dict(options)
106
+ for primary in ("query", "video_id", "handle"):
107
+ value = getattr(args, primary, None)
108
+ if value is not None:
109
+ values[primary] = value
110
+ data = CONTRACTS[source].model_validate(values).model_dump(exclude_none=True, by_alias=True)
111
+ primary = next(key for key in ("query", "video_id", "handle") if key in data)
112
+ payload = {"source": source, primary: data.pop(primary), "params": data}
113
+ if operation == "submit":
114
+ if args.job_id or not args.allow_submit:
115
+ raise FlashDataError(
116
+ "New delivery requires --allow-submit; saved jobs use status/result."
117
+ )
118
+ storage = storage_config(environ.get("FLASHDATA_DOWNLOAD_STORAGE", ""))
119
+ endpoint = storage.get("endpoint_url")
120
+ if endpoint:
121
+ url = urlsplit(endpoint)
122
+ if (
123
+ url.scheme != "https"
124
+ or not url.hostname
125
+ or url.username
126
+ or url.password
127
+ or url.query
128
+ or url.fragment
129
+ ):
130
+ raise FlashDataError("Storage endpoint must be an HTTPS URL without credentials.")
131
+ payload["params"]["storage"] = storage
132
+ return operation, payload
133
+
134
+
135
+ def run(
136
+ source, command, argv=None, *, environ=None, stdout=None, stderr=None, client_factory=Client
137
+ ):
138
+ environ = os.environ if environ is None else environ
139
+ stdout = sys.stdout if stdout is None else stdout
140
+ stderr = sys.stderr if stderr is None else stderr
141
+ output = None
142
+ request_started = False
143
+ output_complete = False
144
+ try:
145
+ args = parser_for(source, command).parse_args(argv)
146
+ if args.schema:
147
+ stdout.write(json.dumps(CONTRACTS[source].model_json_schema(), indent=2) + "\n")
148
+ return 0
149
+ operation, payload = payload_for(source, args, environ)
150
+ key = environ.get("FLASHDATA_API_KEY", "").strip()
151
+ if not key or any(ord(c) < 32 or ord(c) == 127 for c in key):
152
+ raise FlashDataError("Set a valid FLASHDATA_API_KEY environment variable.")
153
+ if args.output:
154
+ # Reserve the new path before spending Credits, without replacing user files.
155
+ fd = os.open(args.output, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
156
+ output = os.fdopen(fd, "w", encoding="utf-8")
157
+ client = client_factory(key)
158
+ request_started = True
159
+ result = (
160
+ client.query(payload)
161
+ if operation == "query"
162
+ else client.submit(payload)
163
+ if operation == "submit"
164
+ else client.status(payload["job_id"])
165
+ if operation == "status"
166
+ else client.result(payload["job_id"])
167
+ )
168
+ destination = output if output else stdout
169
+ destination.write(json.dumps(result, ensure_ascii=False, allow_nan=False, indent=2) + "\n")
170
+ destination.flush()
171
+ output_complete = True
172
+ return 0
173
+ except ValidationError:
174
+ stderr.write("Invalid source input. See --schema for fields, types, ranges and defaults.\n")
175
+ return 2
176
+ except FlashDataError as error:
177
+ stderr.write(str(error) + "\n")
178
+ return 1 if request_started else 2
179
+ except (OSError, ValueError, TypeError):
180
+ stderr.write(
181
+ (
182
+ "Could not save the response. " + UNKNOWN_OUTCOME
183
+ if request_started
184
+ else "Could not prepare output or inputs. Check the new output path."
185
+ )
186
+ + "\n"
187
+ )
188
+ return 1 if request_started else 2
189
+ finally:
190
+ if output:
191
+ output.close()
192
+ if not output_complete:
193
+ stderr.write(
194
+ "The newly reserved output file is incomplete; check Jobs before retrying.\n"
195
+ )
196
+
197
+
198
+ def main():
199
+ from ._capability import COMMAND, SOURCE
200
+
201
+ raise SystemExit(run(SOURCE, COMMAND))
@@ -0,0 +1,87 @@
1
+ {
2
+ "type": "object",
3
+ "additionalProperties": false,
4
+ "required": [
5
+ "storage_type",
6
+ "storage_url"
7
+ ],
8
+ "description": "Customer-owned destination, using the same fields as Download API params.storage. Supply credentials from your configured storage; do not invent values or display secrets. GCS uses service_account; Azure uses account_name and a container SAS with write permission; other providers use access_key/secret_key and provider endpoint/region as needed.",
9
+ "properties": {
10
+ "storage_type": {
11
+ "type": "string",
12
+ "enum": [
13
+ "azure",
14
+ "bos",
15
+ "cos",
16
+ "gcs",
17
+ "oss",
18
+ "s3",
19
+ "s3_compatible",
20
+ "tos"
21
+ ]
22
+ },
23
+ "storage_url": {
24
+ "type": "string",
25
+ "minLength": 1,
26
+ "maxLength": 1024,
27
+ "description": "Bucket name (Azure: container name). Not a local path or a full object URL."
28
+ },
29
+ "object_prefix": {
30
+ "type": "string",
31
+ "minLength": 1,
32
+ "maxLength": 513,
33
+ "description": "Optional object folder prefix. Use a distinct prefix when keeping multiple versions of a video."
34
+ },
35
+ "access_key": {
36
+ "type": "string",
37
+ "maxLength": 4096
38
+ },
39
+ "secret_key": {
40
+ "type": "string",
41
+ "maxLength": 4096
42
+ },
43
+ "endpoint_url": {
44
+ "type": "string",
45
+ "maxLength": 4096,
46
+ "description": "Provider endpoint, required for S3-compatible storage. Must be reachable from the download Worker."
47
+ },
48
+ "region": {
49
+ "type": "string",
50
+ "maxLength": 4096
51
+ },
52
+ "account_name": {
53
+ "type": "string",
54
+ "pattern": "^[a-z0-9]{3,24}$"
55
+ },
56
+ "sas_token": {
57
+ "type": "string",
58
+ "maxLength": 4097,
59
+ "description": "Azure container SAS with write permission; never return this secret to the user in output."
60
+ },
61
+ "service_account": {
62
+ "type": "object",
63
+ "required": [
64
+ "project_id",
65
+ "client_email",
66
+ "private_key"
67
+ ],
68
+ "properties": {
69
+ "project_id": {
70
+ "type": "string",
71
+ "minLength": 1,
72
+ "maxLength": 256
73
+ },
74
+ "client_email": {
75
+ "type": "string",
76
+ "minLength": 1
77
+ },
78
+ "private_key": {
79
+ "type": "string",
80
+ "minLength": 1,
81
+ "maxLength": 16384
82
+ }
83
+ },
84
+ "additionalProperties": true
85
+ }
86
+ }
87
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "additionalProperties": false,
3
+ "properties": {
4
+ "handle": {
5
+ "description": "YouTube channel handle, for example @jawed.",
6
+ "maxLength": 100,
7
+ "minLength": 1,
8
+ "title": "Handle",
9
+ "type": "string"
10
+ },
11
+ "video_limit": {
12
+ "default": 20,
13
+ "maximum": 100,
14
+ "minimum": 1,
15
+ "title": "Video Limit",
16
+ "type": "integer"
17
+ }
18
+ },
19
+ "required": [
20
+ "handle"
21
+ ],
22
+ "title": "YouTubeChannelInput",
23
+ "type": "object"
24
+ }
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27,<2"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "flashdata-youtube-channel"
7
+ version = "0.1.0"
8
+ description = "Snapshot channel metadata and recent videos with the FlashData JSON CLI"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{name = "FlashData", email = "support@flashdata.dev"}]
14
+ keywords = ["youtube-channel", "cli", "json", "uvx", "flashdata"]
15
+ classifiers = ["Intended Audience :: Developers", "Programming Language :: Python :: 3", "Environment :: Console"]
16
+ dependencies = ["httpx>=0.28.1,<1", "pydantic>=2.7.4,<3", "jsonschema>=4.23,<5"]
17
+
18
+ [project.scripts]
19
+ flashdata-youtube-channel = "flashdata_youtube_channel_cli.cli:main"
20
+
21
+ [project.urls]
22
+ Homepage = "https://flashdata.dev/docs/sources/youtube_channel"
23
+ Documentation = "https://flashdata.dev/docs/sources/youtube_channel"
24
+ Pricing = "https://flashdata.dev/pricing"
25
+ Support = "https://flashdata.dev"
26
+ Source = "https://github.com/rocosen/flashdata-mcp/tree/main/packages/python-cli"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["flashdata_youtube_channel_cli"]