From ed2e27bd7f5ec80d7abc37bd019619ff5a3f24f7 Mon Sep 17 00:00:00 2001 From: Jelmer de Wit <1598297+jdwit@users.noreply.github.com> Date: Tue, 9 Jun 2026 08:49:57 +0200 Subject: [PATCH] feat: first-run demo mode (no OAuth required) Adds `ytstudio demo tour` and `YTSTUDIO_DEMO=1` env override so users can try the CLI against a baked fake channel in seconds, without an account or API credentials. Unblocks the "try in 10 seconds" README block and the terminaltrove demo recording. --- README.md | 11 + docs/index.md | 11 + scripts/terminaltrove-demo.tape | 20 ++ src/ytstudio/api.py | 14 ++ src/ytstudio/commands/demo.py | 131 +++++++++++ src/ytstudio/demo_service.py | 202 ++++++++++++++++ src/ytstudio/fixtures/analytics_overview.json | 14 ++ src/ytstudio/fixtures/channel.json | 22 ++ src/ytstudio/fixtures/comments.json | 200 ++++++++++++++++ src/ytstudio/fixtures/playlist_items.json | 89 ++++++++ src/ytstudio/fixtures/videos.json | 215 ++++++++++++++++++ src/ytstudio/main.py | 13 +- src/ytstudio/services.py | 9 + tests/test_demo.py | 105 +++++++++ tests/test_demo_service.py | 103 +++++++++ 15 files changed, 1157 insertions(+), 2 deletions(-) create mode 100644 scripts/terminaltrove-demo.tape create mode 100644 src/ytstudio/commands/demo.py create mode 100644 src/ytstudio/demo_service.py create mode 100644 src/ytstudio/fixtures/analytics_overview.json create mode 100644 src/ytstudio/fixtures/channel.json create mode 100644 src/ytstudio/fixtures/comments.json create mode 100644 src/ytstudio/fixtures/playlist_items.json create mode 100644 src/ytstudio/fixtures/videos.json create mode 100644 tests/test_demo.py create mode 100644 tests/test_demo_service.py diff --git a/README.md b/README.md index 9452c88..0615ecc 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,17 @@ command line. - Comments moderation: list, reply, and moderate from the CLI. - Channel analytics queries via the YouTube Analytics API. +## Try in 10 seconds + +No account, no OAuth, no API setup. Runs against a built-in fake channel: + +```bash +pipx install ytstudio-cli +ytstudio demo tour +``` + +Use `ytstudio init && ytstudio login` to connect your real channel. + ## Documentation See the [full documentation](https://jdwit.github.io/ytstudio-cli/) for installation, OAuth setup, and the command reference. diff --git a/docs/index.md b/docs/index.md index 39c9bb0..52f56f0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,6 +3,17 @@ Manage and analyze your YouTube channel from the terminal. Designed for humans and AI agents. +## Try in 10 seconds + +No account, no OAuth, no API setup. Runs against a built-in fake channel: + +```bash +pipx install ytstudio-cli +ytstudio demo tour +``` + +Use `ytstudio init && ytstudio login` to connect your real channel. + ## What it is A small CLI on top of the official diff --git a/scripts/terminaltrove-demo.tape b/scripts/terminaltrove-demo.tape new file mode 100644 index 0000000..26ef785 --- /dev/null +++ b/scripts/terminaltrove-demo.tape @@ -0,0 +1,20 @@ +# terminaltrove demo tape for ytstudio-cli +# +# Records the first-run demo mode end-to-end against the built-in fake channel. +# No OAuth, no network, no sandbox account required. +# +# Render with: vhs scripts/terminaltrove-demo.tape + +Output scripts/terminaltrove-demo.gif + +Set FontSize 16 +Set Width 1200 +Set Height 720 +Set Padding 20 +Set Theme "Dracula" +Set Shell "bash" + +Type "ytstudio demo tour --no-pauses" +Sleep 500ms +Enter +Sleep 4s diff --git a/src/ytstudio/api.py b/src/ytstudio/api.py index 178be14..6406008 100644 --- a/src/ytstudio/api.py +++ b/src/ytstudio/api.py @@ -236,6 +236,20 @@ def get_authenticated_service( def get_status(profile: str | None = None) -> None: + # Local import avoids importing demo fixtures in the hot real-auth path. + from ytstudio.demo_service import _load_fixture, is_demo_mode # noqa: PLC0415 + + if is_demo_mode(): + channel = _load_fixture("channel.json")["items"][0] + snippet = channel["snippet"] + stats = channel["statistics"] + success_message("Authenticated (demo mode)") + console.print(" mode: demo") + console.print(f" Channel: [bold]{snippet['title']}[/bold]") + console.print(f" Subscribers: {stats.get('subscriberCount', 'N/A')}") + console.print(f" Videos: {stats.get('videoCount', 'N/A')}") + return + creds_data = load_credentials(profile) if not creds_data: diff --git a/src/ytstudio/commands/demo.py b/src/ytstudio/commands/demo.py new file mode 100644 index 0000000..ecbf77d --- /dev/null +++ b/src/ytstudio/commands/demo.py @@ -0,0 +1,131 @@ +"""First-run demo mode: try the CLI against a built-in fake channel. + +Each subcommand sets `YTSTUDIO_DEMO=1` for the duration of the call and then +delegates to the real command function, so users see identical output to the +production code path without needing an account or OAuth credentials. +""" + +import os +import time +from contextlib import contextmanager + +import typer + +from ytstudio.commands import analytics as analytics_cmd +from ytstudio.commands import comments as comments_cmd +from ytstudio.commands import videos as videos_cmd +from ytstudio.ui import console, create_kv_table, dim + +app = typer.Typer(help="Try the CLI against a built-in fake channel - no OAuth required") + + +@contextmanager +def _demo_env(): + previous = os.environ.get("YTSTUDIO_DEMO") + os.environ["YTSTUDIO_DEMO"] = "1" + try: + yield + finally: + if previous is None: + os.environ.pop("YTSTUDIO_DEMO", None) + else: + os.environ["YTSTUDIO_DEMO"] = previous + + +@app.command() +def videos( + limit: int = typer.Option(5, "--limit", "-n", help="Number of videos to list"), + output: str = typer.Option("table", "--output", "-o", help="Output format: table, json"), +): + """List fake videos from the demo channel.""" + with _demo_env(): + videos_cmd.list_videos( + limit=limit, + page_token=None, + sort="date", + output=output, + audio_lang=None, + meta_lang=None, + has_localization=None, + scheduled=False, + ) + + +@app.command() +def analytics( + days: int = typer.Option(28, "--days", "-d", help="Number of days to analyze"), + output: str = typer.Option("table", "--output", "-o", help="Output format: table, json"), +): + """Show fake channel analytics overview.""" + with _demo_env(): + analytics_cmd.overview(days=days, output=output) + + +@app.command() +def comments( + limit: int = typer.Option(10, "--limit", "-n", help="Number of comments"), + output: str = typer.Option("table", "--output", "-o", help="Output format: table, json"), +): + """List fake comments across the demo channel.""" + with _demo_env(): + comments_cmd.list_comments( + video_id=None, + status=comments_cmd.ModerationStatus.published, + limit=limit, + sort=comments_cmd.SortOrder.time, + output=output, + ) + + +@app.command() +def tour( + no_pauses: bool = typer.Option(False, "--no-pauses", help="Skip the pacing sleeps"), +): + """Run videos, analytics, and comments back-to-back.""" + console.print(dim("\nytstudio demo tour: videos -> analytics -> comments\n")) + + with _demo_env(): + console.print(dim("[1/3] videos\n")) + videos_cmd.list_videos( + limit=5, + page_token=None, + sort="date", + output="table", + audio_lang=None, + meta_lang=None, + has_localization=None, + scheduled=False, + ) + if not no_pauses: + time.sleep(1.5) + + console.print(dim("\n[2/3] analytics\n")) + analytics_cmd.overview(days=28, output="table") + if not no_pauses: + time.sleep(1.5) + + console.print(dim("\n[3/3] comments\n")) + comments_cmd.list_comments( + video_id=None, + status=comments_cmd.ModerationStatus.published, + limit=10, + sort=comments_cmd.SortOrder.time, + output="table", + ) + + console.print(dim("\nDone. Connect your real channel with: ytstudio init && ytstudio login\n")) + + +@app.command() +def info(): + """Show what powers demo mode.""" + fixtures_path = "ytstudio/fixtures/ (bundled in the wheel)" + table = create_kv_table() + table.add_column("field", style="dim") + table.add_column("value") + table.add_row("fixtures", fixtures_path) + table.add_row("env var", "YTSTUDIO_DEMO") + table.add_row("channel", "Demo Creator Channel (@democreator)") + table.add_row("network", "none; quota/error paths are bypassed") + console.print(table) + console.print("\n[dim]To use your real channel: ytstudio init && ytstudio login[/dim]") diff --git a/src/ytstudio/demo_service.py b/src/ytstudio/demo_service.py new file mode 100644 index 0000000..20b9e6f --- /dev/null +++ b/src/ytstudio/demo_service.py @@ -0,0 +1,202 @@ +"""Fake YouTube Data + Analytics services for offline demo mode. + +The fake services mimic the small surface of `googleapiclient` resources that +the real ytstudio commands exercise. They are fed by JSON fixtures bundled in +`ytstudio/fixtures/`. No network calls are made. +""" + +from __future__ import annotations + +import functools +import hashlib +import json +import os +import sys +from importlib import resources +from typing import Any, ClassVar + +from rich.console import Console + +_DEMO_ENV = "YTSTUDIO_DEMO" +_TRUTHY = {"1", "true", "yes", "on"} + + +def is_demo_mode() -> bool: + return os.getenv(_DEMO_ENV, "").lower() in _TRUTHY + + +def _wants_json_output() -> bool: + argv = sys.argv[1:] + for i, arg in enumerate(argv): + if arg in {"-o", "--output"} and i + 1 < len(argv) and argv[i + 1] == "json": + return True + if arg in {"-o=json", "--output=json"}: + return True + return False + + +class _BannerState: + """Per-process flag for the demo banner. Module-level mutables trip PLW0603.""" + + printed = False + + +def print_demo_banner_once() -> None: + """Print the demo banner at most once per process, on stderr.""" + if _BannerState.printed or _wants_json_output(): + return + _BannerState.printed = True + Console(stderr=True).print("[dim]demo mode: using built-in fake channel (no network)[/dim]") + + +def _reset_banner_for_tests() -> None: + _BannerState.printed = False + + +@functools.cache +def _load_fixture(name: str) -> Any: + path = resources.files("ytstudio").joinpath("fixtures", name) + with path.open("r", encoding="utf-8") as fh: + return json.load(fh) + + +class _FakeRequest: + def __init__(self, result: dict): + self._result = result + + def execute(self) -> dict: + return self._result + + +class FakeYouTubeService: + """Fake `youtube` v3 Data API service.""" + + def __init__(self) -> None: + # In-process edits so search-replace --execute renders coherently. + self._video_overrides: dict[str, dict] = {} + + def channels(self) -> _FakeChannels: + return _FakeChannels() + + def playlistItems(self) -> _FakePlaylistItems: + return _FakePlaylistItems() + + def videos(self) -> _FakeVideos: + return _FakeVideos(self._video_overrides) + + def videoCategories(self) -> _FakeVideoCategories: + return _FakeVideoCategories() + + def commentThreads(self) -> _FakeCommentThreads: + return _FakeCommentThreads() + + def comments(self) -> _FakeComments: + return _FakeComments() + + def search(self) -> _FakeSearch: + return _FakeSearch() + + +class _FakeChannels: + def list(self, **_kwargs) -> _FakeRequest: + return _FakeRequest(_load_fixture("channel.json")) + + +class _FakePlaylistItems: + def list(self, **_kwargs) -> _FakeRequest: + return _FakeRequest(_load_fixture("playlist_items.json")) + + +class _FakeVideos: + def __init__(self, overrides: dict[str, dict]): + self._overrides = overrides + + def list(self, **kwargs) -> _FakeRequest: + videos = _load_fixture("videos.json")["items"] + wanted_ids = kwargs.get("id") + if wanted_ids: + ids = [i.strip() for i in wanted_ids.split(",") if i.strip()] + by_id = {v["id"]: v for v in videos} + picked = [by_id[i] for i in ids if i in by_id] + else: + picked = list(videos) + + merged = [] + for item in picked: + override = self._overrides.get(item["id"]) + if override: + copy = json.loads(json.dumps(item)) + copy["snippet"].update(override.get("snippet", {})) + merged.append(copy) + else: + merged.append(item) + return _FakeRequest({"items": merged}) + + def update(self, **kwargs) -> _FakeRequest: + body = kwargs.get("body", {}) + vid = body.get("id", "demo_vid_unknown") + if "snippet" in body: + self._overrides[vid] = {"snippet": body["snippet"]} + return _FakeRequest({"id": vid}) + + +class _FakeVideoCategories: + def list(self, **_kwargs) -> _FakeRequest: + items = [ + {"id": "22", "snippet": {"title": "People & Blogs", "assignable": True}}, + {"id": "26", "snippet": {"title": "Howto & Style", "assignable": True}}, + {"id": "27", "snippet": {"title": "Education", "assignable": True}}, + {"id": "28", "snippet": {"title": "Science & Technology", "assignable": True}}, + ] + return _FakeRequest({"items": items}) + + +class _FakeCommentThreads: + def list(self, **_kwargs) -> _FakeRequest: + return _FakeRequest(_load_fixture("comments.json")) + + +class _FakeComments: + def setModerationStatus(self, **_kwargs) -> _FakeRequest: + return _FakeRequest({}) + + +class _FakeSearch: + def list(self, **_kwargs) -> _FakeRequest: + videos = _load_fixture("videos.json")["items"] + items = [{"id": {"videoId": v["id"]}} for v in videos] + return _FakeRequest({"items": items, "nextPageToken": None}) + + +class FakeAnalyticsService: + """Fake `youtubeAnalytics` v2 service.""" + + def reports(self) -> _FakeReports: + return _FakeReports() + + +class _FakeReports: + _OVERVIEW_METRICS: ClassVar[set[str]] = { + "views", + "estimatedMinutesWatched", + "averageViewDuration", + "subscribersGained", + "subscribersLost", + "likes", + "comments", + } + + def query(self, **kwargs) -> _FakeRequest: + metrics = [m.strip() for m in kwargs.get("metrics", "").split(",") if m.strip()] + if set(metrics) == self._OVERVIEW_METRICS: + return _FakeRequest(_load_fixture("analytics_overview.json")) + # Stable-seeded synthetic response for any other metric combo. + row = [_pseudo_value(m) for m in metrics] + return _FakeRequest( + {"columnHeaders": [{"name": m} for m in metrics], "rows": [row] if row else []} + ) + + +def _pseudo_value(metric: str) -> int: + digest = hashlib.md5(metric.encode()).hexdigest() + return int(digest[:8], 16) % 100_000 diff --git a/src/ytstudio/fixtures/analytics_overview.json b/src/ytstudio/fixtures/analytics_overview.json new file mode 100644 index 0000000..a796357 --- /dev/null +++ b/src/ytstudio/fixtures/analytics_overview.json @@ -0,0 +1,14 @@ +{ + "columnHeaders": [ + {"name": "views"}, + {"name": "estimatedMinutesWatched"}, + {"name": "averageViewDuration"}, + {"name": "subscribersGained"}, + {"name": "subscribersLost"}, + {"name": "likes"}, + {"name": "comments"} + ], + "rows": [ + [123456, 78900, 245, 1200, 30, 4500, 380] + ] +} diff --git a/src/ytstudio/fixtures/channel.json b/src/ytstudio/fixtures/channel.json new file mode 100644 index 0000000..d266047 --- /dev/null +++ b/src/ytstudio/fixtures/channel.json @@ -0,0 +1,22 @@ +{ + "items": [ + { + "id": "UC_demo_channel", + "snippet": { + "title": "Demo Creator Channel", + "description": "A built-in fake channel used by ytstudio demo mode. No network calls, no OAuth required.", + "customUrl": "@democreator" + }, + "statistics": { + "subscriberCount": "48230", + "viewCount": "2840000", + "videoCount": "42" + }, + "contentDetails": { + "relatedPlaylists": { + "uploads": "UU_demo_uploads" + } + } + } + ] +} diff --git a/src/ytstudio/fixtures/comments.json b/src/ytstudio/fixtures/comments.json new file mode 100644 index 0000000..c7a9d9a --- /dev/null +++ b/src/ytstudio/fixtures/comments.json @@ -0,0 +1,200 @@ +{ + "items": [ + { + "id": "demo_cmt_001", + "snippet": { + "topLevelComment": { + "snippet": { + "authorDisplayName": "Maya Ortiz", + "textOriginal": "The cabin tour was so detailed, thanks for showing the wood stove install.", + "textDisplay": "The cabin tour was so detailed, thanks for showing the wood stove install.", + "likeCount": 142, + "publishedAt": "2026-06-05T08:12:00Z", + "videoId": "demo_vid_001" + } + } + } + }, + { + "id": "demo_cmt_002", + "snippet": { + "topLevelComment": { + "snippet": { + "authorDisplayName": "Ben Liu", + "textOriginal": "Could you share the wiring diagram for the solar setup?", + "textDisplay": "Could you share the wiring diagram for the solar setup?", + "likeCount": 88, + "publishedAt": "2026-06-04T19:45:00Z", + "videoId": "demo_vid_002" + } + } + } + }, + { + "id": "demo_cmt_003", + "snippet": { + "topLevelComment": { + "snippet": { + "authorDisplayName": "Priya Shah", + "textOriginal": "The day-job story hit hard. Watched it twice.", + "textDisplay": "The day-job story hit hard. Watched it twice.", + "likeCount": 211, + "publishedAt": "2026-06-04T12:01:00Z", + "videoId": "demo_vid_003" + } + } + } + }, + { + "id": "demo_cmt_004", + "snippet": { + "topLevelComment": { + "snippet": { + "authorDisplayName": "Jonas Becker", + "textOriginal": "Audio levels were a bit low in the second half.", + "textDisplay": "Audio levels were a bit low in the second half.", + "likeCount": 12, + "publishedAt": "2026-06-03T22:18:00Z", + "videoId": "demo_vid_008" + } + } + } + }, + { + "id": "demo_cmt_005", + "snippet": { + "topLevelComment": { + "snippet": { + "authorDisplayName": "Aisha Khan", + "textOriginal": "Greenhouse tour please do a follow-up in fall.", + "textDisplay": "Greenhouse tour please do a follow-up in fall.", + "likeCount": 47, + "publishedAt": "2026-06-03T14:55:00Z", + "videoId": "demo_vid_005" + } + } + } + }, + { + "id": "demo_cmt_006", + "snippet": { + "topLevelComment": { + "snippet": { + "authorDisplayName": "Tom Reilly", + "textOriginal": "Honestly this video felt rushed and the edit was choppy.", + "textDisplay": "Honestly this video felt rushed and the edit was choppy.", + "likeCount": 3, + "publishedAt": "2026-06-02T17:08:00Z", + "videoId": "demo_vid_009" + } + } + } + }, + { + "id": "demo_cmt_007", + "snippet": { + "topLevelComment": { + "snippet": { + "authorDisplayName": "Hannah West", + "textOriginal": "I made the rainwater system from your plans, works great!", + "textDisplay": "I made the rainwater system from your plans, works great!", + "likeCount": 95, + "publishedAt": "2026-06-02T09:30:00Z", + "videoId": "demo_vid_004" + } + } + } + }, + { + "id": "demo_cmt_008", + "snippet": { + "topLevelComment": { + "snippet": { + "authorDisplayName": "Luca Romano", + "textOriginal": "Where do you get your shop tools? Local or online?", + "textDisplay": "Where do you get your shop tools? Local or online?", + "likeCount": 22, + "publishedAt": "2026-06-01T20:00:00Z", + "videoId": "demo_vid_007" + } + } + } + }, + { + "id": "demo_cmt_009", + "snippet": { + "topLevelComment": { + "snippet": { + "authorDisplayName": "Sofia Almeida", + "textOriginal": "Loved the camera comparison in the editing video.", + "textDisplay": "Loved the camera comparison in the editing video.", + "likeCount": 38, + "publishedAt": "2026-06-01T11:22:00Z", + "videoId": "demo_vid_008" + } + } + } + }, + { + "id": "demo_cmt_010", + "snippet": { + "topLevelComment": { + "snippet": { + "authorDisplayName": "Ravi Patel", + "textOriginal": "Not into the storytime stuff, prefer the build videos.", + "textDisplay": "Not into the storytime stuff, prefer the build videos.", + "likeCount": 5, + "publishedAt": "2026-05-31T15:40:00Z", + "videoId": "demo_vid_003" + } + } + } + }, + { + "id": "demo_cmt_011", + "snippet": { + "topLevelComment": { + "snippet": { + "authorDisplayName": "Elena Voss", + "textOriginal": "First-frost lessons saved my tomato bed, thank you!", + "textDisplay": "First-frost lessons saved my tomato bed, thank you!", + "likeCount": 61, + "publishedAt": "2026-05-31T07:14:00Z", + "videoId": "demo_vid_006" + } + } + } + }, + { + "id": "demo_cmt_012", + "snippet": { + "topLevelComment": { + "snippet": { + "authorDisplayName": "Kenji Mori", + "textOriginal": "The axe sharpening close-ups were beautiful.", + "textDisplay": "The axe sharpening close-ups were beautiful.", + "likeCount": 73, + "publishedAt": "2026-05-30T18:55:00Z", + "videoId": "demo_vid_007" + } + } + } + }, + { + "id": "demo_cmt_013", + "snippet": { + "topLevelComment": { + "snippet": { + "authorDisplayName": "Grace Hopper", + "textOriginal": "Awesome roadmap. Hyped for the next series.", + "textDisplay": "Awesome roadmap. Hyped for the next series.", + "likeCount": 29, + "publishedAt": "2026-05-30T10:01:00Z", + "videoId": "demo_vid_009" + } + } + } + } + ], + "nextPageToken": null +} diff --git a/src/ytstudio/fixtures/playlist_items.json b/src/ytstudio/fixtures/playlist_items.json new file mode 100644 index 0000000..6b4473b --- /dev/null +++ b/src/ytstudio/fixtures/playlist_items.json @@ -0,0 +1,89 @@ +{ + "items": [ + { + "snippet": { + "title": "Building a Cabin Off-Grid", + "publishedAt": "2026-03-12T14:00:00Z" + }, + "contentDetails": { + "videoId": "demo_vid_001" + } + }, + { + "snippet": { + "title": "Solar Sizing for a Tiny House", + "publishedAt": "2026-04-02T11:30:00Z" + }, + "contentDetails": { + "videoId": "demo_vid_002" + } + }, + { + "snippet": { + "title": "Why I Quit My Day Job", + "publishedAt": "2026-04-19T09:00:00Z" + }, + "contentDetails": { + "videoId": "demo_vid_003" + } + }, + { + "snippet": { + "title": "Rainwater Harvesting on a Budget", + "publishedAt": "2026-04-28T15:15:00Z" + }, + "contentDetails": { + "videoId": "demo_vid_004" + } + }, + { + "snippet": { + "title": "The Greenhouse Tour", + "publishedAt": "2026-05-07T13:00:00Z" + }, + "contentDetails": { + "videoId": "demo_vid_005" + } + }, + { + "snippet": { + "title": "First Frost: What I Should Have Done", + "publishedAt": "2026-05-14T08:00:00Z" + }, + "contentDetails": { + "videoId": "demo_vid_006" + } + }, + { + "snippet": { + "title": "Workshop: Sharpening an Axe Properly", + "publishedAt": "2026-05-21T17:45:00Z" + }, + "contentDetails": { + "videoId": "demo_vid_007" + } + }, + { + "snippet": { + "title": "How I Edit These Videos", + "publishedAt": "2026-06-01T12:00:00Z" + }, + "contentDetails": { + "videoId": "demo_vid_008" + } + }, + { + "snippet": { + "title": "Channel Update: What is Coming Next", + "publishedAt": "2026-06-06T19:00:00Z" + }, + "contentDetails": { + "videoId": "demo_vid_009" + } + } + ], + "pageInfo": { + "totalResults": 9 + }, + "nextPageToken": null +} diff --git a/src/ytstudio/fixtures/videos.json b/src/ytstudio/fixtures/videos.json new file mode 100644 index 0000000..f2a7c8c --- /dev/null +++ b/src/ytstudio/fixtures/videos.json @@ -0,0 +1,215 @@ +{ + "items": [ + { + "id": "demo_vid_001", + "snippet": { + "title": "Building a Cabin Off-Grid", + "description": "Year one in the woods: foundations, framing, and the wood stove install.", + "publishedAt": "2026-03-12T14:00:00Z", + "tags": ["off-grid", "cabin", "diy", "homestead"], + "categoryId": "26", + "defaultLanguage": "en", + "defaultAudioLanguage": "en" + }, + "statistics": { + "viewCount": "184230", + "likeCount": "9120", + "commentCount": "612" + }, + "contentDetails": { + "duration": "PT18M42S" + }, + "status": { + "privacyStatus": "public" + }, + "localizations": { + "nl": { + "title": "Een Hut Off-Grid Bouwen", + "description": "Jaar een in het bos." + } + } + }, + { + "id": "demo_vid_002", + "snippet": { + "title": "Solar Sizing for a Tiny House", + "description": "How I sized a 2 kW PV array for full-time tiny-house living.", + "publishedAt": "2026-04-02T11:30:00Z", + "tags": ["solar", "tiny-house", "energy"], + "categoryId": "28", + "defaultLanguage": "en", + "defaultAudioLanguage": "en" + }, + "statistics": { + "viewCount": "92410", + "likeCount": "4880", + "commentCount": "318" + }, + "contentDetails": { + "duration": "PT12M05S" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "id": "demo_vid_003", + "snippet": { + "title": "Why I Quit My Day Job", + "description": "Three numbers that made the math work, and one that almost killed it.", + "publishedAt": "2026-04-19T09:00:00Z", + "tags": ["story", "career", "freelance"], + "categoryId": "22", + "defaultLanguage": "en", + "defaultAudioLanguage": "en" + }, + "statistics": { + "viewCount": "412980", + "likeCount": "22140", + "commentCount": "1840" + }, + "contentDetails": { + "duration": "PT9M47S" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "id": "demo_vid_004", + "snippet": { + "title": "Rainwater Harvesting on a Budget", + "description": "1500 liters of storage for under 400 dollars in parts.", + "publishedAt": "2026-04-28T15:15:00Z", + "tags": ["water", "homestead", "budget"], + "categoryId": "26" + }, + "statistics": { + "viewCount": "68240", + "likeCount": "3210", + "commentCount": "204" + }, + "contentDetails": { + "duration": "PT7M22S" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "id": "demo_vid_005", + "snippet": { + "title": "The Greenhouse Tour", + "description": "Walkthrough of the geodome with planting plan for the season.", + "publishedAt": "2026-05-07T13:00:00Z", + "tags": ["greenhouse", "garden", "tour"], + "categoryId": "26" + }, + "statistics": { + "viewCount": "54100", + "likeCount": "2980", + "commentCount": "176" + }, + "contentDetails": { + "duration": "PT11M58S" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "id": "demo_vid_006", + "snippet": { + "title": "First Frost: What I Should Have Done", + "description": "Five mistakes from last winter and how I am prepping this year.", + "publishedAt": "2026-05-14T08:00:00Z", + "tags": ["winter", "lessons", "homestead"], + "categoryId": "26", + "defaultLanguage": "en", + "defaultAudioLanguage": "en" + }, + "statistics": { + "viewCount": "29840", + "likeCount": "1620", + "commentCount": "143" + }, + "contentDetails": { + "duration": "PT8M14S" + }, + "status": { + "privacyStatus": "public" + }, + "localizations": { + "nl": { + "title": "Eerste Vorst: Wat Ik Had Moeten Doen", + "description": "Vijf lessen van vorige winter." + } + } + }, + { + "id": "demo_vid_007", + "snippet": { + "title": "Workshop: Sharpening an Axe Properly", + "description": "From rusted thrift-store find to working tool in 20 minutes.", + "publishedAt": "2026-05-21T17:45:00Z", + "tags": ["tools", "workshop", "sharpening"], + "categoryId": "26" + }, + "statistics": { + "viewCount": "17320", + "likeCount": "1140", + "commentCount": "92" + }, + "contentDetails": { + "duration": "PT6M03S" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "id": "demo_vid_008", + "snippet": { + "title": "How I Edit These Videos", + "description": "Camera, mics, and the editing workflow that keeps shipping weekly.", + "publishedAt": "2026-06-01T12:00:00Z", + "tags": ["editing", "workflow", "meta"], + "categoryId": "27", + "defaultLanguage": "en", + "defaultAudioLanguage": "en" + }, + "statistics": { + "viewCount": "11280", + "likeCount": "910", + "commentCount": "74" + }, + "contentDetails": { + "duration": "PT14M11S" + }, + "status": { + "privacyStatus": "public" + } + }, + { + "id": "demo_vid_009", + "snippet": { + "title": "Channel Update: What is Coming Next", + "description": "Three series I am planning, plus a Q and A at the end.", + "publishedAt": "2026-06-06T19:00:00Z", + "tags": ["update", "channel", "qna"], + "categoryId": "22" + }, + "statistics": { + "viewCount": "4820", + "likeCount": "412", + "commentCount": "38" + }, + "contentDetails": { + "duration": "PT4M52S" + }, + "status": { + "privacyStatus": "public" + } + } + ] +} diff --git a/src/ytstudio/main.py b/src/ytstudio/main.py index 57339a3..4d377a0 100644 --- a/src/ytstudio/main.py +++ b/src/ytstudio/main.py @@ -3,8 +3,9 @@ import typer from rich.console import Console +from ytstudio import demo_service from ytstudio.api import authenticate, get_status -from ytstudio.commands import analytics, comments, livestreams, profile, videos +from ytstudio.commands import analytics, comments, demo, livestreams, profile, videos from ytstudio.config import migrate_legacy_credentials, setup_credentials from ytstudio.version import get_current_version, is_update_available @@ -13,6 +14,7 @@ help="Manage your YouTube channel from the terminal", no_args_is_help=True, rich_markup_mode="markdown", + epilog="Tip: run [bold]ytstudio demo tour[/bold] to try the CLI without an account.", ) console = Console() @@ -22,6 +24,7 @@ app.add_typer(comments.app, name="comments") app.add_typer(livestreams.app, name="livestreams") app.add_typer(profile.app, name="profile") +app.add_typer(demo.app, name="demo") @app.command() @@ -63,7 +66,7 @@ def _show_update_notification(): available, latest = is_update_available() if available: console.print( - f"\n[cyan]Update available: {get_current_version()} → {latest}[/cyan]\n" + f"\n[cyan]Update available: {get_current_version()} -> {latest}[/cyan]\n" f"Run: [bold]uv tool upgrade ytstudio-cli[/bold]" ) except Exception: @@ -72,6 +75,7 @@ def _show_update_notification(): @app.callback(invoke_without_command=True) def main( + ctx: typer.Context, show_version: bool = typer.Option(False, "--version", "-v", help="Show version"), ): """ytstudio - Manage your YouTube channel from the terminal""" @@ -81,6 +85,11 @@ def main( migrate_legacy_credentials() + # Only print the demo banner when an actual subcommand is being invoked, + # not for bare `ytstudio` or `ytstudio --version`. + if ctx.invoked_subcommand is not None and demo_service.is_demo_mode(): + demo_service.print_demo_banner_once() + if not _update_state["registered"]: atexit.register(_show_update_notification) _update_state["registered"] = True diff --git a/src/ytstudio/services.py b/src/ytstudio/services.py index 6a152b4..c46ac68 100644 --- a/src/ytstudio/services.py +++ b/src/ytstudio/services.py @@ -2,8 +2,17 @@ def get_data_service(profile: str | None = None): + # Local import keeps the real auth path zero-overhead when demo mode is off. + from ytstudio.demo_service import FakeYouTubeService, is_demo_mode # noqa: PLC0415 + + if is_demo_mode(): + return FakeYouTubeService() return get_authenticated_service("youtube", "v3", profile=profile) def get_analytics_service(profile: str | None = None): + from ytstudio.demo_service import FakeAnalyticsService, is_demo_mode # noqa: PLC0415 + + if is_demo_mode(): + return FakeAnalyticsService() return get_authenticated_service("youtubeAnalytics", "v2", profile=profile) diff --git a/tests/test_demo.py b/tests/test_demo.py new file mode 100644 index 0000000..50f08a1 --- /dev/null +++ b/tests/test_demo.py @@ -0,0 +1,105 @@ +import json +from unittest.mock import patch + +import pytest +from typer.testing import CliRunner + +from ytstudio import demo_service +from ytstudio.main import app + +runner = CliRunner() + + +@pytest.fixture(autouse=True) +def _clear_demo_env(monkeypatch): + """Each test starts with a clean YTSTUDIO_DEMO state and a fresh banner flag.""" + monkeypatch.delenv("YTSTUDIO_DEMO", raising=False) + demo_service._reset_banner_for_tests() + yield + + +def test_demo_videos_uses_fixture_data(): + result = runner.invoke(app, ["demo", "videos"]) + assert result.exit_code == 0, result.stdout + assert "Building a Cabin" in result.stdout + + +def test_demo_videos_json_output(): + result = runner.invoke(app, ["demo", "videos", "-o", "json"]) + assert result.exit_code == 0, result.stdout + payload = json.loads(result.stdout) + assert isinstance(payload["videos"], list) + assert len(payload["videos"]) >= 5 + + +def test_demo_analytics_renders_overview(): + result = runner.invoke(app, ["demo", "analytics"]) + assert result.exit_code == 0, result.stdout + # Either the human format `123.5K` or any digit run shows something rendered. + assert any(ch.isdigit() for ch in result.stdout) + assert "views" in result.stdout.lower() + + +def test_demo_comments_lists_fake_comments(): + result = runner.invoke(app, ["demo", "comments"]) + assert result.exit_code == 0, result.stdout + assert "Maya Ortiz" in result.stdout or "Ben Liu" in result.stdout + + +def test_demo_tour_runs_all_three_sections(): + result = runner.invoke(app, ["demo", "tour", "--no-pauses"]) + assert result.exit_code == 0, result.stdout + assert "Building a Cabin" in result.stdout + assert "views" in result.stdout.lower() + assert "Maya Ortiz" in result.stdout or "Priya Shah" in result.stdout + + +def test_demo_info_lists_sources_and_env_var(): + result = runner.invoke(app, ["demo", "info"]) + assert result.exit_code == 0, result.stdout + assert "YTSTUDIO_DEMO" in result.stdout + assert "fixtures" in result.stdout + + +def test_env_var_routes_existing_videos_list_to_fake(monkeypatch): + monkeypatch.setenv("YTSTUDIO_DEMO", "1") + with patch("ytstudio.api.build") as build_mock: + result = runner.invoke(app, ["videos", "list"]) + assert result.exit_code == 0, result.stdout + assert "Building a Cabin" in result.stdout + build_mock.assert_not_called() + + +def test_env_var_routes_analytics_overview_to_fake(monkeypatch): + monkeypatch.setenv("YTSTUDIO_DEMO", "1") + with patch("ytstudio.api.build") as build_mock: + result = runner.invoke(app, ["analytics", "overview"]) + assert result.exit_code == 0, result.stdout + assert "views" in result.stdout.lower() + build_mock.assert_not_called() + + +def test_env_var_status_short_circuits_without_credentials(monkeypatch): + monkeypatch.setenv("YTSTUDIO_DEMO", "1") + with patch("ytstudio.api.load_credentials") as load_mock: + result = runner.invoke(app, ["status"]) + assert result.exit_code == 0, result.stdout + assert "demo" in result.stdout.lower() + load_mock.assert_not_called() + + +def test_demo_banner_printed_once(monkeypatch, capsys): + monkeypatch.setenv("YTSTUDIO_DEMO", "1") + demo_service._reset_banner_for_tests() + monkeypatch.setattr("sys.argv", ["ytstudio", "demo", "info"]) + demo_service.print_demo_banner_once() + demo_service.print_demo_banner_once() + captured = capsys.readouterr() + assert captured.err.count("demo mode") == 1 + + +def test_real_path_unaffected_when_env_unset(monkeypatch, mock_auth): + monkeypatch.delenv("YTSTUDIO_DEMO", raising=False) + result = runner.invoke(app, ["videos", "list"]) + assert result.exit_code == 0, result.stdout + mock_auth.channels.return_value.list.assert_called() diff --git a/tests/test_demo_service.py b/tests/test_demo_service.py new file mode 100644 index 0000000..df34274 --- /dev/null +++ b/tests/test_demo_service.py @@ -0,0 +1,103 @@ +import pytest + +from ytstudio import demo_service +from ytstudio.demo_service import ( + FakeAnalyticsService, + FakeYouTubeService, + _load_fixture, + is_demo_mode, +) + + +@pytest.mark.parametrize("value", ["1", "true", "TRUE", "yes", "on"]) +def test_is_demo_mode_truthy_values(monkeypatch, value): + monkeypatch.setenv("YTSTUDIO_DEMO", value) + assert is_demo_mode() is True + + +@pytest.mark.parametrize("value", ["", "0", "false", "no"]) +def test_is_demo_mode_falsy_values(monkeypatch, value): + monkeypatch.setenv("YTSTUDIO_DEMO", value) + assert is_demo_mode() is False + + +def test_fake_youtube_channels_list_returns_fixture(): + response = FakeYouTubeService().channels().list(part="contentDetails", mine=True).execute() + uploads = response["items"][0]["contentDetails"]["relatedPlaylists"]["uploads"] + assert uploads == "UU_demo_uploads" + + +def test_fake_youtube_videos_list_returns_requested_ids(): + response = ( + FakeYouTubeService().videos().list(part="snippet", id="demo_vid_001,demo_vid_002").execute() + ) + items = response["items"] + assert len(items) == 2 + assert {item["id"] for item in items} == {"demo_vid_001", "demo_vid_002"} + + +def test_fake_youtube_playlist_items_paginate_envelope(): + response = FakeYouTubeService().playlistItems().list().execute() + fixture = _load_fixture("playlist_items.json") + assert response["pageInfo"]["totalResults"] == len(fixture["items"]) + assert response["nextPageToken"] is None + + +def test_fake_analytics_query_returns_column_headers_and_rows(): + response = ( + FakeAnalyticsService() + .reports() + .query( + ids="channel==X", + startDate="2026-01-01", + endDate="2026-01-31", + metrics="views,likes", + ) + .execute() + ) + assert [h["name"] for h in response["columnHeaders"]] == ["views", "likes"] + assert len(response["rows"]) == 1 + assert len(response["rows"][0]) == 2 + + +def test_fixtures_load_via_importlib_resources(): + videos = _load_fixture("videos.json") + assert isinstance(videos["items"], list) + assert len(videos["items"]) > 0 + + +def test_fake_videos_update_records_override(): + service = FakeYouTubeService() + result = ( + service.videos() + .update(part="snippet", body={"id": "demo_vid_001", "snippet": {"title": "Renamed"}}) + .execute() + ) + assert result == {"id": "demo_vid_001"} + + listed = service.videos().list(part="snippet", id="demo_vid_001").execute() + assert listed["items"][0]["snippet"]["title"] == "Renamed" + + +def test_fake_comments_set_moderation_status_returns_empty(): + response = ( + FakeYouTubeService().comments().setModerationStatus(id="x", moderationStatus="published") + ) + assert response.execute() == {} + + +def test_print_demo_banner_once(capsys, monkeypatch): + demo_service._reset_banner_for_tests() + monkeypatch.setattr("sys.argv", ["ytstudio", "videos", "list"]) + demo_service.print_demo_banner_once() + demo_service.print_demo_banner_once() + captured = capsys.readouterr() + assert captured.err.count("demo mode") == 1 + + +def test_print_demo_banner_skipped_for_json(capsys, monkeypatch): + demo_service._reset_banner_for_tests() + monkeypatch.setattr("sys.argv", ["ytstudio", "videos", "list", "-o", "json"]) + demo_service.print_demo_banner_once() + captured = capsys.readouterr() + assert "demo mode" not in captured.err