ysaas 0.1.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.
- ysaas/__init__.py +3 -0
- ysaas/__main__.py +3 -0
- ysaas/cli.py +508 -0
- ysaas/config.py +195 -0
- ysaas/db.py +198 -0
- ysaas/downloader.py +49 -0
- ysaas/filters.py +39 -0
- ysaas/models.py +11 -0
- ysaas/monitor.py +33 -0
- ysaas/notifier.py +22 -0
- ysaas/pipeline.py +185 -0
- ysaas/uploader.py +118 -0
- ysaas-0.1.0.dist-info/METADATA +77 -0
- ysaas-0.1.0.dist-info/RECORD +18 -0
- ysaas-0.1.0.dist-info/WHEEL +5 -0
- ysaas-0.1.0.dist-info/entry_points.txt +2 -0
- ysaas-0.1.0.dist-info/licenses/LICENSE +22 -0
- ysaas-0.1.0.dist-info/top_level.txt +1 -0
ysaas/__init__.py
ADDED
ysaas/__main__.py
ADDED
ysaas/cli.py
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
"""Terminal interface: `ysaas <command>` or the interactive `ysaas menu`."""
|
|
2
|
+
import logging
|
|
3
|
+
import re
|
|
4
|
+
import shutil
|
|
5
|
+
from logging.handlers import RotatingFileHandler
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.markup import escape
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.prompt import Prompt
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
|
|
16
|
+
from . import __version__
|
|
17
|
+
from .config import (
|
|
18
|
+
CHANNEL_ID_RE,
|
|
19
|
+
DEFAULTS,
|
|
20
|
+
ENV_VAR,
|
|
21
|
+
ConfigError,
|
|
22
|
+
Settings,
|
|
23
|
+
find_config,
|
|
24
|
+
load_raw,
|
|
25
|
+
load_settings,
|
|
26
|
+
parse_settings,
|
|
27
|
+
save_raw,
|
|
28
|
+
)
|
|
29
|
+
from .db import SKIPPED, UPLOADED, Database
|
|
30
|
+
from .notifier import notify
|
|
31
|
+
from .pipeline import Hooks, default_service_factory, run_download, run_upload
|
|
32
|
+
from .uploader import AuthError, authenticate
|
|
33
|
+
|
|
34
|
+
app = typer.Typer(
|
|
35
|
+
help="Monitor YouTube channels you have permission to reuse, download their Shorts, "
|
|
36
|
+
"and upload them to your own channel.",
|
|
37
|
+
no_args_is_help=True,
|
|
38
|
+
add_completion=False,
|
|
39
|
+
)
|
|
40
|
+
channels_app = typer.Typer(help="Manage source channels.", no_args_is_help=True)
|
|
41
|
+
app.add_typer(channels_app, name="channels")
|
|
42
|
+
|
|
43
|
+
console = Console()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------- helpers
|
|
47
|
+
def _fail(message: str) -> None:
|
|
48
|
+
console.print(f"[red]Error:[/red] {escape(message)}")
|
|
49
|
+
raise typer.Exit(1)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _settings(ctx: typer.Context) -> Settings:
|
|
53
|
+
path = find_config(ctx.obj["config"])
|
|
54
|
+
try:
|
|
55
|
+
return load_settings(path)
|
|
56
|
+
except ConfigError as e:
|
|
57
|
+
_fail(str(e))
|
|
58
|
+
raise # unreachable; keeps type checkers happy
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _logger(settings: Settings) -> logging.Logger:
|
|
62
|
+
logger = logging.getLogger("ysaas")
|
|
63
|
+
if not logger.handlers:
|
|
64
|
+
settings.log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
handler = RotatingFileHandler(settings.log_path, maxBytes=1_000_000, backupCount=3, encoding="utf-8")
|
|
66
|
+
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
|
67
|
+
logger.addHandler(handler)
|
|
68
|
+
logger.setLevel(logging.INFO)
|
|
69
|
+
return logger
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _hooks(settings: Settings, status=None) -> Hooks:
|
|
73
|
+
logger = _logger(settings)
|
|
74
|
+
|
|
75
|
+
def log(message: str) -> None:
|
|
76
|
+
console.print(message, markup=False, highlight=False)
|
|
77
|
+
logger.info(message)
|
|
78
|
+
|
|
79
|
+
def progress(title: str, fraction: float) -> None:
|
|
80
|
+
status.update(f"Uploading {escape(title)}: {fraction:.0%}")
|
|
81
|
+
|
|
82
|
+
def send_notification(title: str, message: str) -> None:
|
|
83
|
+
notify(title, message, settings.discord_webhook)
|
|
84
|
+
logger.info("Notification: %s - %s", title, message)
|
|
85
|
+
|
|
86
|
+
return Hooks(log=log, notify=send_notification, upload_progress=progress if status is not None else None)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ------------------------------------------------------------ core actions
|
|
90
|
+
def _do_status(settings: Settings) -> None:
|
|
91
|
+
db = Database(settings.db_path)
|
|
92
|
+
counts = db.status_counts()
|
|
93
|
+
table = Table(title="YSAAS status", show_header=False)
|
|
94
|
+
table.add_column("Item")
|
|
95
|
+
table.add_column("Value", justify="right")
|
|
96
|
+
table.add_row("Source channels", str(len(settings.channels)))
|
|
97
|
+
table.add_row("Downloaded today (UTC)", f"{db.count_downloaded_today()}/{settings.max_downloads_per_day}")
|
|
98
|
+
table.add_row("Uploaded today (UTC)", f"{db.count_uploaded_today()}/{settings.max_uploads_per_day}")
|
|
99
|
+
table.add_row(
|
|
100
|
+
"Waiting to upload",
|
|
101
|
+
f"{db.backlog_count(settings.max_attempts)} (downloads pause at {settings.backlog_pause_threshold})",
|
|
102
|
+
)
|
|
103
|
+
stuck = db.stuck_count(settings.max_attempts)
|
|
104
|
+
table.add_row("Failed, out of retries", f"[red]{stuck}[/red]" if stuck else "0")
|
|
105
|
+
table.add_row("Uploaded in total", str(counts[UPLOADED]))
|
|
106
|
+
table.add_row("Skipped (not Shorts)", str(counts[SKIPPED]))
|
|
107
|
+
console.print(table)
|
|
108
|
+
if stuck:
|
|
109
|
+
console.print("Run [bold]ysaas retry[/bold] to give failed items fresh attempts.")
|
|
110
|
+
|
|
111
|
+
rows = db.recent(10)
|
|
112
|
+
if rows:
|
|
113
|
+
recent = Table(title="Recent activity")
|
|
114
|
+
for col in ("Title", "Channel", "Status", "Error"):
|
|
115
|
+
recent.add_column(col)
|
|
116
|
+
for r in rows:
|
|
117
|
+
error = (r["error"] or "")[:60]
|
|
118
|
+
recent.add_row(escape(r["title"][:50]), escape(r["channel_name"]), r["status"], escape(error))
|
|
119
|
+
console.print(recent)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _print_download_report(report) -> None:
|
|
123
|
+
console.print(
|
|
124
|
+
f"[bold]Download:[/bold] {report.downloaded} downloaded, "
|
|
125
|
+
f"{report.skipped} skipped (not Shorts), {report.failed} failed"
|
|
126
|
+
)
|
|
127
|
+
if report.stopped:
|
|
128
|
+
console.print(f"[yellow]Note:[/yellow] {escape(report.stopped)}")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _print_upload_report(report) -> None:
|
|
132
|
+
console.print(f"[bold]Upload:[/bold] {report.uploaded} uploaded, {report.failed} failed")
|
|
133
|
+
if report.stopped:
|
|
134
|
+
console.print(f"[yellow]Note:[/yellow] {escape(report.stopped)}")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _do_download(settings: Settings) -> None:
|
|
138
|
+
db = Database(settings.db_path)
|
|
139
|
+
hooks = _hooks(settings)
|
|
140
|
+
with console.status("Checking channels and downloading..."):
|
|
141
|
+
report = run_download(settings, db, hooks)
|
|
142
|
+
_print_download_report(report)
|
|
143
|
+
if report.downloaded or report.failed or report.stopped:
|
|
144
|
+
hooks.notify("YSAAS download", f"{report.downloaded} downloaded, {report.skipped} skipped, {report.failed} failed. {report.stopped or ''}".strip())
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _do_upload(settings: Settings) -> None:
|
|
148
|
+
db = Database(settings.db_path)
|
|
149
|
+
status = console.status("Preparing upload...")
|
|
150
|
+
|
|
151
|
+
def factory(s: Settings):
|
|
152
|
+
status.stop() # let the browser sign-in flow print cleanly on first run
|
|
153
|
+
try:
|
|
154
|
+
return default_service_factory(s)
|
|
155
|
+
finally:
|
|
156
|
+
status.start()
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
with status:
|
|
160
|
+
report = run_upload(settings, db, _hooks(settings, status), service_factory=factory)
|
|
161
|
+
except AuthError as e:
|
|
162
|
+
_fail(str(e))
|
|
163
|
+
return
|
|
164
|
+
_print_upload_report(report)
|
|
165
|
+
if report.uploaded or report.failed or report.stopped:
|
|
166
|
+
_hooks(settings).notify("YSAAS upload", f"{report.uploaded} uploaded, {report.failed} failed. {report.stopped or ''}".strip())
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _do_run(settings: Settings) -> None:
|
|
170
|
+
_do_download(settings)
|
|
171
|
+
_do_upload(settings) # runs even if downloads paused: uploading is how the backlog drains
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _do_auth(settings: Settings) -> None:
|
|
175
|
+
console.print(
|
|
176
|
+
"A browser window will open. Sign in with the Google account that manages the upload channel "
|
|
177
|
+
f"for [bold]{escape(settings.upload_channel or settings.target_channel)}[/bold]. The token authorizes the channel "
|
|
178
|
+
"associated with that account; verify the selected account/channel before continuing."
|
|
179
|
+
)
|
|
180
|
+
try:
|
|
181
|
+
authenticate(settings.client_secret_path, settings.token_path)
|
|
182
|
+
except AuthError as e:
|
|
183
|
+
_fail(str(e))
|
|
184
|
+
return
|
|
185
|
+
console.print(f"[green]Signed in.[/green] Token saved to {escape(str(settings.token_path))}")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _do_retry(settings: Settings) -> None:
|
|
189
|
+
count = Database(settings.db_path).reset_failed()
|
|
190
|
+
console.print(f"Reset {count} failed item(s); they will be retried on the next run.")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _do_doctor(settings: Settings) -> None:
|
|
194
|
+
checks = [
|
|
195
|
+
("Settings file loads", True, ""),
|
|
196
|
+
("Source channel(s) configured", bool(settings.channels), "run: ysaas channels add NAME CHANNEL_ID"),
|
|
197
|
+
("ffmpeg installed", shutil.which("ffmpeg") is not None, "install ffmpeg (needed to merge video and audio)"),
|
|
198
|
+
("client_secret.json present", settings.client_secret_path.exists(), f"save it at {settings.client_secret_path}"),
|
|
199
|
+
("Upload channel entered", bool(settings.upload_channel), "run: ysaas setup"),
|
|
200
|
+
("Signed in to YouTube", settings.token_path.exists(), "run: ysaas auth"),
|
|
201
|
+
]
|
|
202
|
+
table = Table(title="Setup check")
|
|
203
|
+
table.add_column("Check")
|
|
204
|
+
table.add_column("Result")
|
|
205
|
+
table.add_column("Fix")
|
|
206
|
+
for label, ok, hint in checks:
|
|
207
|
+
table.add_row(label, "[green]OK[/green]" if ok else "[red]MISSING[/red]", "" if ok else escape(hint))
|
|
208
|
+
console.print(table)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# --------------------------------------------------------------- setup and logs
|
|
212
|
+
def _setup_profile(ctx: typer.Context) -> None:
|
|
213
|
+
"""Interactive first-run setup; credentials remain in the user's ignored config folder."""
|
|
214
|
+
path = find_config(ctx.obj["config"])
|
|
215
|
+
try:
|
|
216
|
+
raw = load_raw(path) if path.exists() else dict(DEFAULTS)
|
|
217
|
+
except ConfigError as e:
|
|
218
|
+
_fail(str(e))
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
console.print(Panel(
|
|
222
|
+
"Uploads require OAuth consent from each user. A Google API key alone cannot upload videos.\n"
|
|
223
|
+
"Create an OAuth client ID for a Desktop app in Google Cloud, enable YouTube Data API v3,\n"
|
|
224
|
+
"and provide that downloaded client JSON below. Do not paste an API key or token here.",
|
|
225
|
+
title="YouTube account setup",
|
|
226
|
+
))
|
|
227
|
+
current_profile = str(raw.get("target_channel", "main"))
|
|
228
|
+
profile = Prompt.ask("Upload profile name (used to keep this user's token separate)", default=current_profile).strip()
|
|
229
|
+
if profile:
|
|
230
|
+
raw["target_channel"] = profile
|
|
231
|
+
raw["upload_channel"] = Prompt.ask(
|
|
232
|
+
"Intended YouTube upload channel name or URL (reminder for OAuth sign-in)",
|
|
233
|
+
default=str(raw.get("upload_channel", "")),
|
|
234
|
+
).strip()
|
|
235
|
+
|
|
236
|
+
current_secret = path.parent / "client_secret.json"
|
|
237
|
+
source = Prompt.ask(
|
|
238
|
+
"Path to your OAuth Desktop app JSON (blank keeps existing file)",
|
|
239
|
+
default=str(current_secret) if current_secret.exists() else "",
|
|
240
|
+
).strip().strip('"')
|
|
241
|
+
if source:
|
|
242
|
+
source_path = Path(source).expanduser()
|
|
243
|
+
if not source_path.is_file():
|
|
244
|
+
_fail(f"OAuth JSON file not found: {source_path}")
|
|
245
|
+
return
|
|
246
|
+
try:
|
|
247
|
+
import json
|
|
248
|
+
document = json.loads(source_path.read_text(encoding="utf-8"))
|
|
249
|
+
if not isinstance(document, dict) or not (document.get("installed") or document.get("web")):
|
|
250
|
+
raise ValueError("expected a Google OAuth client JSON with an installed or web section")
|
|
251
|
+
except (OSError, ValueError) as e:
|
|
252
|
+
_fail(f"Could not read OAuth client JSON: {e}")
|
|
253
|
+
return
|
|
254
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
255
|
+
current_secret.write_text(source_path.read_text(encoding="utf-8"), encoding="utf-8")
|
|
256
|
+
console.print(f"Saved OAuth client JSON to {escape(str(current_secret))} (git-ignored).")
|
|
257
|
+
|
|
258
|
+
channels = raw.get("channels", [])
|
|
259
|
+
if not isinstance(channels, list):
|
|
260
|
+
channels = []
|
|
261
|
+
console.print("Add source channels you own or have written permission to reuse. Leave name blank when done.")
|
|
262
|
+
while True:
|
|
263
|
+
name = Prompt.ask("Source channel name", default="").strip()
|
|
264
|
+
if not name:
|
|
265
|
+
break
|
|
266
|
+
channel = Prompt.ask("Channel ID, channel URL, or @handle").strip()
|
|
267
|
+
try:
|
|
268
|
+
channel_id = _resolve_channel_id(channel)
|
|
269
|
+
except Exception as e:
|
|
270
|
+
console.print(f"[yellow]Could not resolve that channel:[/yellow] {escape(str(e))}")
|
|
271
|
+
continue
|
|
272
|
+
channels = [item for item in channels if str(item.get("name", "")).casefold() != name.casefold()]
|
|
273
|
+
channels.append({"name": name, "channel_id": channel_id})
|
|
274
|
+
console.print(f"Added {escape(name)} ({channel_id}).")
|
|
275
|
+
raw["channels"] = channels
|
|
276
|
+
|
|
277
|
+
webhook = Prompt.ask(
|
|
278
|
+
"Optional Discord webhook URL for alerts (blank disables notifications)",
|
|
279
|
+
default=str(raw.get("discord_webhook", "")),
|
|
280
|
+
).strip()
|
|
281
|
+
raw["discord_webhook"] = webhook
|
|
282
|
+
try:
|
|
283
|
+
parse_settings(raw, path)
|
|
284
|
+
save_raw(path, raw)
|
|
285
|
+
except ConfigError as e:
|
|
286
|
+
_fail(str(e))
|
|
287
|
+
return
|
|
288
|
+
console.print(Panel(
|
|
289
|
+
f"Saved settings to {escape(str(path))}.\n\n"
|
|
290
|
+
f"Upload profile: {escape(profile or current_profile)}\n"
|
|
291
|
+
f"Source channels: {len(channels)}\n"
|
|
292
|
+
f"OAuth client: {'ready' if current_secret.exists() else 'not set'}\n"
|
|
293
|
+
f"Logs: {escape(str(path.parent.parent / 'logs' / 'ysaas.log'))}\n\n"
|
|
294
|
+
"Next: run `ysaas auth`, confirm the intended Google account/channel, then run `ysaas doctor`.",
|
|
295
|
+
title="Setup saved",
|
|
296
|
+
))
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _show_logs(settings: Settings) -> None:
|
|
300
|
+
limit = 80
|
|
301
|
+
if not settings.log_path.exists():
|
|
302
|
+
console.print(f"No log file yet. It will be created at {escape(str(settings.log_path))} when a run starts.")
|
|
303
|
+
return
|
|
304
|
+
lines = settings.log_path.read_text(encoding="utf-8", errors="replace").splitlines()[-limit:]
|
|
305
|
+
console.print(Panel("\n".join(escape(line) for line in lines) or "Log is empty", title=f"Recent logs ({settings.log_path})"))
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# --------------------------------------------------------------- commands
|
|
309
|
+
def _version(value: bool) -> None:
|
|
310
|
+
if value:
|
|
311
|
+
console.print(f"ysaas {__version__}")
|
|
312
|
+
raise typer.Exit()
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@app.callback()
|
|
316
|
+
def main(
|
|
317
|
+
ctx: typer.Context,
|
|
318
|
+
config: Optional[Path] = typer.Option(
|
|
319
|
+
None, "--config", "-c", envvar=ENV_VAR, help="Path to settings.json (default: ./config/settings.json)."
|
|
320
|
+
),
|
|
321
|
+
version: bool = typer.Option(False, "--version", callback=_version, is_eager=True, help="Show version and exit."),
|
|
322
|
+
) -> None:
|
|
323
|
+
ctx.obj = {"config": config}
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@app.command()
|
|
327
|
+
def init(ctx: typer.Context) -> None:
|
|
328
|
+
"""Create a settings file and folders in the current directory."""
|
|
329
|
+
path = find_config(ctx.obj["config"])
|
|
330
|
+
if path.exists():
|
|
331
|
+
console.print(f"Settings already exist at {escape(str(path))}; leaving them alone.")
|
|
332
|
+
else:
|
|
333
|
+
save_raw(path, DEFAULTS)
|
|
334
|
+
console.print(f"Created {escape(str(path))}")
|
|
335
|
+
(path.parent / "tokens").mkdir(parents=True, exist_ok=True)
|
|
336
|
+
console.print(
|
|
337
|
+
Panel(
|
|
338
|
+
"Run `ysaas setup` to configure your OAuth client, source channels, upload profile, and alerts.\n"
|
|
339
|
+
"Then run `ysaas auth` to authorize the upload channel, `ysaas doctor` to check setup,\n"
|
|
340
|
+
"and `ysaas run` to process videos. Use `ysaas logs` to inspect activity.",
|
|
341
|
+
title="Next steps",
|
|
342
|
+
)
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@app.command()
|
|
347
|
+
def setup(ctx: typer.Context) -> None:
|
|
348
|
+
"""Interactively configure OAuth, source channels, upload profile and alerts."""
|
|
349
|
+
_setup_profile(ctx)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@app.command()
|
|
353
|
+
def logs(ctx: typer.Context) -> None:
|
|
354
|
+
"""Show the latest log entries."""
|
|
355
|
+
_show_logs(_settings(ctx))
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@app.command()
|
|
359
|
+
def status(ctx: typer.Context) -> None:
|
|
360
|
+
"""Show today's progress, the upload backlog and recent activity."""
|
|
361
|
+
_do_status(_settings(ctx))
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@app.command()
|
|
365
|
+
def download(ctx: typer.Context) -> None:
|
|
366
|
+
"""Look for new Shorts on the source channels and download them."""
|
|
367
|
+
_do_download(_settings(ctx))
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@app.command()
|
|
371
|
+
def upload(ctx: typer.Context) -> None:
|
|
372
|
+
"""Upload downloaded videos to your channel."""
|
|
373
|
+
_do_upload(_settings(ctx))
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
@app.command()
|
|
377
|
+
def run(ctx: typer.Context) -> None:
|
|
378
|
+
"""Download, then upload. This is the command to schedule with cron."""
|
|
379
|
+
_do_run(_settings(ctx))
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
@app.command()
|
|
383
|
+
def auth(ctx: typer.Context) -> None:
|
|
384
|
+
"""Sign in to the YouTube channel you upload to."""
|
|
385
|
+
_do_auth(_settings(ctx))
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
@app.command()
|
|
389
|
+
def retry(ctx: typer.Context) -> None:
|
|
390
|
+
"""Give failed downloads and uploads a fresh set of attempts."""
|
|
391
|
+
_do_retry(_settings(ctx))
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
@app.command()
|
|
395
|
+
def doctor(ctx: typer.Context) -> None:
|
|
396
|
+
"""Check that everything needed is installed and configured."""
|
|
397
|
+
_do_doctor(_settings(ctx))
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
@app.command()
|
|
401
|
+
def menu(ctx: typer.Context) -> None:
|
|
402
|
+
"""Interactive menu."""
|
|
403
|
+
settings = _settings(ctx)
|
|
404
|
+
actions = {
|
|
405
|
+
"1": ("Show status", _do_status),
|
|
406
|
+
"2": ("Download new Shorts", _do_download),
|
|
407
|
+
"3": ("Upload waiting videos", _do_upload),
|
|
408
|
+
"4": ("Download, then upload", _do_run),
|
|
409
|
+
"5": ("Sign in to YouTube", _do_auth),
|
|
410
|
+
"6": ("Check setup", _do_doctor),
|
|
411
|
+
"7": ("Retry failed items", _do_retry),
|
|
412
|
+
"8": ("View logs", _show_logs),
|
|
413
|
+
"9": ("Setup / edit profile", lambda _settings: _setup_profile(ctx)),
|
|
414
|
+
}
|
|
415
|
+
body = "\n".join(f"[bold]{key}[/bold] {label}" for key, (label, _) in actions.items())
|
|
416
|
+
while True:
|
|
417
|
+
console.print(Panel(body + "\n[bold]q[/bold] Quit", title=f"YSAAS {__version__}"))
|
|
418
|
+
choice = Prompt.ask("Choose", choices=[*actions, "q"], default="1")
|
|
419
|
+
if choice == "q":
|
|
420
|
+
break
|
|
421
|
+
actions[choice][1](settings)
|
|
422
|
+
console.print()
|
|
423
|
+
if choice == "9":
|
|
424
|
+
break
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
# --------------------------------------------------------------- channels
|
|
428
|
+
def _resolve_channel_id(text: str) -> str:
|
|
429
|
+
"""Accept a channel ID, a /channel/UC... URL, or an @handle / handle URL."""
|
|
430
|
+
text = text.strip()
|
|
431
|
+
if CHANNEL_ID_RE.match(text):
|
|
432
|
+
return text
|
|
433
|
+
match = re.search(r"youtube\.com/channel/(UC[\w-]{22})", text)
|
|
434
|
+
if match:
|
|
435
|
+
return match.group(1)
|
|
436
|
+
|
|
437
|
+
import yt_dlp
|
|
438
|
+
|
|
439
|
+
url = text if text.startswith("http") else f"https://www.youtube.com/{text if text.startswith('@') else '@' + text}"
|
|
440
|
+
opts = {"quiet": True, "no_warnings": True, "skip_download": True, "extract_flat": True, "playlistend": 1}
|
|
441
|
+
with yt_dlp.YoutubeDL(opts) as ydl:
|
|
442
|
+
info = ydl.extract_info(url, download=False)
|
|
443
|
+
for key in ("channel_id", "uploader_id"):
|
|
444
|
+
value = (info or {}).get(key)
|
|
445
|
+
if value and CHANNEL_ID_RE.match(value):
|
|
446
|
+
return value
|
|
447
|
+
raise ValueError("could not find a channel ID")
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
@channels_app.command("list")
|
|
451
|
+
def channels_list(ctx: typer.Context) -> None:
|
|
452
|
+
"""List source channels."""
|
|
453
|
+
settings = _settings(ctx)
|
|
454
|
+
if not settings.channels:
|
|
455
|
+
console.print("No source channels yet. Add one with: ysaas channels add NAME CHANNEL_ID")
|
|
456
|
+
return
|
|
457
|
+
table = Table(title="Source channels")
|
|
458
|
+
table.add_column("Name")
|
|
459
|
+
table.add_column("Channel ID")
|
|
460
|
+
for channel in settings.channels:
|
|
461
|
+
table.add_row(escape(channel.name), channel.channel_id)
|
|
462
|
+
console.print(table)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
@channels_app.command("add")
|
|
466
|
+
def channels_add(
|
|
467
|
+
ctx: typer.Context,
|
|
468
|
+
name: str = typer.Argument(..., help="A label for this channel, e.g. 'MyOtherChannel'."),
|
|
469
|
+
channel: str = typer.Argument(..., help="Channel ID (UC...), channel URL, or @handle."),
|
|
470
|
+
) -> None:
|
|
471
|
+
"""Add a source channel. Only add channels you own or have permission to reuse."""
|
|
472
|
+
path = find_config(ctx.obj["config"])
|
|
473
|
+
try:
|
|
474
|
+
channel_id = _resolve_channel_id(channel)
|
|
475
|
+
except Exception:
|
|
476
|
+
_fail(
|
|
477
|
+
"Could not work out the channel ID from that. Paste the ID itself instead "
|
|
478
|
+
"(it starts with UC and is 24 characters long)."
|
|
479
|
+
)
|
|
480
|
+
return
|
|
481
|
+
try:
|
|
482
|
+
raw = load_raw(path)
|
|
483
|
+
raw["channels"] = [*raw.get("channels", []), {"name": name.strip(), "channel_id": channel_id}]
|
|
484
|
+
parse_settings(raw, path) # validates, including duplicate names
|
|
485
|
+
save_raw(path, raw)
|
|
486
|
+
except ConfigError as e:
|
|
487
|
+
_fail(str(e))
|
|
488
|
+
return
|
|
489
|
+
console.print(f"Added [bold]{escape(name)}[/bold] ({channel_id}).")
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
@channels_app.command("remove")
|
|
493
|
+
def channels_remove(ctx: typer.Context, name: str = typer.Argument(..., help="Name of the channel to remove.")) -> None:
|
|
494
|
+
"""Remove a source channel."""
|
|
495
|
+
path = find_config(ctx.obj["config"])
|
|
496
|
+
try:
|
|
497
|
+
raw = load_raw(path)
|
|
498
|
+
except ConfigError as e:
|
|
499
|
+
_fail(str(e))
|
|
500
|
+
return
|
|
501
|
+
before = raw.get("channels", [])
|
|
502
|
+
after = [c for c in before if str(c.get("name", "")).lower() != name.strip().lower()]
|
|
503
|
+
if len(after) == len(before):
|
|
504
|
+
_fail(f"No channel named '{name}'.")
|
|
505
|
+
return
|
|
506
|
+
raw["channels"] = after
|
|
507
|
+
save_raw(path, raw)
|
|
508
|
+
console.print(f"Removed {escape(name)}.")
|