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 ADDED
@@ -0,0 +1,3 @@
1
+ """YSAAS: monitor, download and re-upload YouTube Shorts (with permission)."""
2
+
3
+ __version__ = "0.1.0"
ysaas/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import app
2
+
3
+ app()
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)}.")