podojo-cli 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.
- podojo_cli-0.1.0/.github/workflows/publish.yml +20 -0
- podojo_cli-0.1.0/.gitignore +6 -0
- podojo_cli-0.1.0/CLAUDE.md +40 -0
- podojo_cli-0.1.0/PKG-INFO +28 -0
- podojo_cli-0.1.0/README.md +15 -0
- podojo_cli-0.1.0/pyproject.toml +27 -0
- podojo_cli-0.1.0/src/podojo_cli/__init__.py +0 -0
- podojo_cli-0.1.0/src/podojo_cli/client.py +54 -0
- podojo_cli-0.1.0/src/podojo_cli/commands/__init__.py +0 -0
- podojo_cli-0.1.0/src/podojo_cli/commands/auth.py +43 -0
- podojo_cli-0.1.0/src/podojo_cli/commands/gdrive.py +51 -0
- podojo_cli-0.1.0/src/podojo_cli/commands/projects.py +27 -0
- podojo_cli-0.1.0/src/podojo_cli/commands/showreel.py +108 -0
- podojo_cli-0.1.0/src/podojo_cli/commands/transcripts.py +43 -0
- podojo_cli-0.1.0/src/podojo_cli/commands/videos.py +57 -0
- podojo_cli-0.1.0/src/podojo_cli/config.py +39 -0
- podojo_cli-0.1.0/src/podojo_cli/gdrive/__init__.py +0 -0
- podojo_cli-0.1.0/src/podojo_cli/gdrive/list.py +20 -0
- podojo_cli-0.1.0/src/podojo_cli/gdrive/upload.py +56 -0
- podojo_cli-0.1.0/src/podojo_cli/main.py +19 -0
- podojo_cli-0.1.0/src/podojo_cli/video/__init__.py +0 -0
- podojo_cli-0.1.0/src/podojo_cli/video/showreel.py +69 -0
- podojo_cli-0.1.0/tests/conftest.py +18 -0
- podojo_cli-0.1.0/tests/test_auth.py +42 -0
- podojo_cli-0.1.0/tests/test_gdrive.py +81 -0
- podojo_cli-0.1.0/tests/test_projects.py +28 -0
- podojo_cli-0.1.0/tests/test_showreel.py +57 -0
- podojo_cli-0.1.0/uv.lock +631 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
permissions:
|
|
11
|
+
contents: read
|
|
12
|
+
id-token: write
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
|
|
16
|
+
- uses: astral-sh/setup-uv@v4
|
|
17
|
+
|
|
18
|
+
- run: uv build
|
|
19
|
+
|
|
20
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Podojo CLI
|
|
2
|
+
|
|
3
|
+
CLI tool for the Podojo user research platform. Part of the Podojo multi-repo architecture.
|
|
4
|
+
|
|
5
|
+
## Tech Stack
|
|
6
|
+
|
|
7
|
+
- Python 3.12, uv for dependency management
|
|
8
|
+
- Typer for CLI framework
|
|
9
|
+
- httpx for HTTP client
|
|
10
|
+
- Rich for terminal output
|
|
11
|
+
- ffmpeg for video editing (system dependency, install via `brew install ffmpeg`)
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
podojo auth login <api-key>
|
|
17
|
+
podojo auth logout
|
|
18
|
+
podojo projects list
|
|
19
|
+
podojo transcripts list <project>
|
|
20
|
+
podojo transcripts download <project> <batch_id> -o output.txt
|
|
21
|
+
podojo videos list <project>
|
|
22
|
+
podojo videos download <batch_id> -o output.mp4
|
|
23
|
+
podojo showreel create clips.json -o showreel.mp4
|
|
24
|
+
podojo gdrive setup ~/.podojo-gdrive.json
|
|
25
|
+
podojo gdrive upload report.md --title "My Report"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Configuration
|
|
29
|
+
|
|
30
|
+
Set via env vars or `~/.podojo.toml`:
|
|
31
|
+
|
|
32
|
+
```toml
|
|
33
|
+
base_url = "https://your-api.example.com"
|
|
34
|
+
api_key = "your-api-key"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Git Commits
|
|
38
|
+
|
|
39
|
+
- Do NOT add "Co-Authored-By: Claude" trailers to commit messages
|
|
40
|
+
- Keep commit messages concise and focused on the "why"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: podojo-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI for the Podojo user research platform
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: google-api-python-client>=2.0
|
|
8
|
+
Requires-Dist: google-auth>=2.0
|
|
9
|
+
Requires-Dist: httpx>=0.28
|
|
10
|
+
Requires-Dist: rich>=13.0
|
|
11
|
+
Requires-Dist: typer>=0.15
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# podojo-cli
|
|
15
|
+
|
|
16
|
+
CLI for the Podojo user research platform.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
uv tool install podojo-cli
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
podojo --help
|
|
28
|
+
```
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "podojo-cli"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "CLI for the Podojo user research platform"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.12"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"typer>=0.15",
|
|
10
|
+
"httpx>=0.28",
|
|
11
|
+
"rich>=13.0",
|
|
12
|
+
"google-auth>=2.0",
|
|
13
|
+
"google-api-python-client>=2.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
podojo = "podojo_cli.main:app"
|
|
18
|
+
|
|
19
|
+
[dependency-groups]
|
|
20
|
+
dev = ["pytest>=8.0", "pytest-httpx>=0.35"]
|
|
21
|
+
|
|
22
|
+
[build-system]
|
|
23
|
+
requires = ["hatchling"]
|
|
24
|
+
build-backend = "hatchling.build"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.wheel]
|
|
27
|
+
packages = ["src/podojo_cli"]
|
|
File without changes
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
|
|
3
|
+
from .config import load_config
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PodojoClient:
|
|
7
|
+
def __init__(self):
|
|
8
|
+
config = load_config()
|
|
9
|
+
self.base_url = config["base_url"].rstrip("/") + "/api/v1"
|
|
10
|
+
self.api_key = config["api_key"]
|
|
11
|
+
|
|
12
|
+
def _headers(self) -> dict:
|
|
13
|
+
return {"Authorization": f"Bearer {self.api_key}"}
|
|
14
|
+
|
|
15
|
+
def list_projects(self, skip: int = 0, limit: int = 50) -> list:
|
|
16
|
+
r = httpx.get(
|
|
17
|
+
f"{self.base_url}/projects",
|
|
18
|
+
params={"skip": skip, "limit": limit},
|
|
19
|
+
headers=self._headers(),
|
|
20
|
+
)
|
|
21
|
+
r.raise_for_status()
|
|
22
|
+
return r.json()["projects"]
|
|
23
|
+
|
|
24
|
+
def list_transcripts(self, project: str) -> dict:
|
|
25
|
+
r = httpx.get(
|
|
26
|
+
f"{self.base_url}/projects/{project}/transcripts",
|
|
27
|
+
headers=self._headers(),
|
|
28
|
+
)
|
|
29
|
+
r.raise_for_status()
|
|
30
|
+
return r.json()
|
|
31
|
+
|
|
32
|
+
def download_transcript(self, project: str, batch_id: str) -> str:
|
|
33
|
+
r = httpx.get(
|
|
34
|
+
f"{self.base_url}/projects/{project}/transcripts/{batch_id}",
|
|
35
|
+
headers=self._headers(),
|
|
36
|
+
)
|
|
37
|
+
r.raise_for_status()
|
|
38
|
+
return r.text
|
|
39
|
+
|
|
40
|
+
def list_videos(self, project: str) -> dict:
|
|
41
|
+
r = httpx.get(
|
|
42
|
+
f"{self.base_url}/projects/{project}/videos",
|
|
43
|
+
headers=self._headers(),
|
|
44
|
+
)
|
|
45
|
+
r.raise_for_status()
|
|
46
|
+
return r.json()
|
|
47
|
+
|
|
48
|
+
def get_video_url(self, batch_id: str) -> dict:
|
|
49
|
+
r = httpx.get(
|
|
50
|
+
f"{self.base_url}/videos/{batch_id}",
|
|
51
|
+
headers=self._headers(),
|
|
52
|
+
)
|
|
53
|
+
r.raise_for_status()
|
|
54
|
+
return r.json()
|
|
File without changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
import typer
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
|
|
5
|
+
from ..config import DEFAULT_BASE_URL, clear_api_key, load_config, save_config
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(help="Authenticate with Podojo")
|
|
8
|
+
console = Console()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _verify_key(api_key: str) -> bool:
|
|
12
|
+
config = load_config()
|
|
13
|
+
base_url = config["base_url"].rstrip("/") + "/api/v1"
|
|
14
|
+
try:
|
|
15
|
+
r = httpx.get(
|
|
16
|
+
f"{base_url}/projects",
|
|
17
|
+
params={"limit": 1},
|
|
18
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
19
|
+
timeout=10,
|
|
20
|
+
)
|
|
21
|
+
return r.status_code == 200
|
|
22
|
+
except httpx.RequestError:
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command("login")
|
|
27
|
+
def login(api_key: str = typer.Argument(help="Your Podojo API key")):
|
|
28
|
+
"""Save your API key and verify it works."""
|
|
29
|
+
console.print("Verifying key…", end=" ")
|
|
30
|
+
if not _verify_key(api_key):
|
|
31
|
+
console.print("[red]failed[/red]")
|
|
32
|
+
console.print("[red]Error:[/red] Invalid API key or could not reach the server.")
|
|
33
|
+
raise typer.Exit(1)
|
|
34
|
+
save_config(api_key)
|
|
35
|
+
console.print("[green]done[/green]")
|
|
36
|
+
console.print("[green]Logged in successfully.[/green]")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@app.command("logout")
|
|
40
|
+
def logout():
|
|
41
|
+
"""Remove the saved API key."""
|
|
42
|
+
clear_api_key()
|
|
43
|
+
console.print("Logged out.")
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
from ..gdrive.list import list_files
|
|
7
|
+
from ..gdrive.upload import CREDENTIALS_FILENAME, upload_md_as_doc
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(help="Upload documents to Google Drive")
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@app.command("upload")
|
|
14
|
+
def upload(
|
|
15
|
+
file: Path = typer.Argument(help="Markdown file to upload"),
|
|
16
|
+
folder_id: str = typer.Option(..., "--folder-id", "-f", help="Google Drive folder ID"),
|
|
17
|
+
title: str = typer.Option(None, "--title", "-t", help="Document title (defaults to filename)"),
|
|
18
|
+
):
|
|
19
|
+
"""Upload a markdown file to Google Drive as a Google Doc."""
|
|
20
|
+
if not file.exists():
|
|
21
|
+
console.print(f"[red]Error:[/red] File not found: {file}")
|
|
22
|
+
raise typer.Exit(1)
|
|
23
|
+
|
|
24
|
+
if not Path(CREDENTIALS_FILENAME).exists():
|
|
25
|
+
console.print(f"[red]Error:[/red] {CREDENTIALS_FILENAME} not found in current directory")
|
|
26
|
+
raise typer.Exit(1)
|
|
27
|
+
|
|
28
|
+
console.print(f"Uploading [bold]{file}[/bold]…", end=" ")
|
|
29
|
+
_, url = upload_md_as_doc(str(file), folder_id=folder_id, title=title)
|
|
30
|
+
console.print("[green]done[/green]")
|
|
31
|
+
console.print(url)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.command("list")
|
|
35
|
+
def list_cmd(
|
|
36
|
+
folder_id: str = typer.Option(..., "--folder-id", "-f", help="Google Drive folder ID"),
|
|
37
|
+
):
|
|
38
|
+
"""List files in a Google Drive folder."""
|
|
39
|
+
if not Path(CREDENTIALS_FILENAME).exists():
|
|
40
|
+
console.print(f"[red]Error:[/red] {CREDENTIALS_FILENAME} not found in current directory")
|
|
41
|
+
raise typer.Exit(1)
|
|
42
|
+
|
|
43
|
+
files = list_files(folder_id=folder_id)
|
|
44
|
+
|
|
45
|
+
if not files:
|
|
46
|
+
console.print("No files found.")
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
for f in files:
|
|
50
|
+
link = f.get("webViewLink", "")
|
|
51
|
+
console.print(f"[bold]{f['name']}[/bold] {link}")
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from rich.table import Table
|
|
4
|
+
|
|
5
|
+
from ..client import PodojoClient
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(help="Manage projects")
|
|
8
|
+
console = Console()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@app.command("list")
|
|
12
|
+
def list_projects(
|
|
13
|
+
skip: int = typer.Option(0, help="Number of projects to skip"),
|
|
14
|
+
limit: int = typer.Option(50, help="Max projects to return"),
|
|
15
|
+
):
|
|
16
|
+
"""List all projects."""
|
|
17
|
+
client = PodojoClient()
|
|
18
|
+
projects = client.list_projects(skip=skip, limit=limit)
|
|
19
|
+
|
|
20
|
+
table = Table(title="Projects")
|
|
21
|
+
table.add_column("Name")
|
|
22
|
+
table.add_column("Brief")
|
|
23
|
+
|
|
24
|
+
for p in projects:
|
|
25
|
+
table.add_row(p.get("name", ""), p.get("brief", ""))
|
|
26
|
+
|
|
27
|
+
console.print(table)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, DownloadColumn
|
|
11
|
+
|
|
12
|
+
from ..client import PodojoClient
|
|
13
|
+
from ..video.showreel import extract_clip, make_title_card, concatenate
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(help="Generate video showreels")
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _check_ffmpeg():
|
|
20
|
+
if shutil.which("ffmpeg") is None:
|
|
21
|
+
console.print("[red]Error:[/red] ffmpeg not found. Install it with: brew install ffmpeg")
|
|
22
|
+
raise typer.Exit(1)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _download_video(url: str, dest: Path):
|
|
26
|
+
with httpx.stream("GET", url, follow_redirects=True) as r:
|
|
27
|
+
r.raise_for_status()
|
|
28
|
+
total = int(r.headers.get("content-length", 0))
|
|
29
|
+
with open(dest, "wb") as f, Progress(
|
|
30
|
+
SpinnerColumn(),
|
|
31
|
+
TextColumn("{task.description}"),
|
|
32
|
+
BarColumn(),
|
|
33
|
+
DownloadColumn(),
|
|
34
|
+
console=console,
|
|
35
|
+
) as progress:
|
|
36
|
+
task = progress.add_task(f" Downloading {dest.name}…", total=total or None)
|
|
37
|
+
for chunk in r.iter_bytes(chunk_size=8192):
|
|
38
|
+
f.write(chunk)
|
|
39
|
+
progress.update(task, advance=len(chunk))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.command("create")
|
|
43
|
+
def create_showreel(
|
|
44
|
+
clips_json: Path = typer.Argument(help="Path to clips JSON file"),
|
|
45
|
+
output: Path = typer.Option("showreel.mp4", "-o", "--output", help="Output .mp4 path"),
|
|
46
|
+
):
|
|
47
|
+
"""Create a video showreel from a clips JSON config.
|
|
48
|
+
|
|
49
|
+
The clips JSON should be a list of objects with these fields:
|
|
50
|
+
batch_id - video batch ID to fetch from the API
|
|
51
|
+
participant - display name (e.g. "P09 — Umid")
|
|
52
|
+
country - participant country
|
|
53
|
+
topic - short description of what the clip shows
|
|
54
|
+
start - clip start time (MM:SS or HH:MM:SS)
|
|
55
|
+
end - clip end time (MM:SS or HH:MM:SS)
|
|
56
|
+
"""
|
|
57
|
+
_check_ffmpeg()
|
|
58
|
+
|
|
59
|
+
with open(clips_json) as f:
|
|
60
|
+
clips = json.load(f)
|
|
61
|
+
|
|
62
|
+
if not clips:
|
|
63
|
+
console.print("[yellow]No clips found in JSON.[/yellow]")
|
|
64
|
+
raise typer.Exit(0)
|
|
65
|
+
|
|
66
|
+
client = PodojoClient()
|
|
67
|
+
output = output.resolve()
|
|
68
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
|
|
70
|
+
with tempfile.TemporaryDirectory(prefix="podojo-showreel-") as tmp:
|
|
71
|
+
tmp = Path(tmp)
|
|
72
|
+
|
|
73
|
+
# Download each unique batch_id once
|
|
74
|
+
video_paths: dict[str, Path] = {}
|
|
75
|
+
unique_ids = {c["batch_id"] for c in clips}
|
|
76
|
+
console.print(f"\nDownloading {len(unique_ids)} video(s)…")
|
|
77
|
+
|
|
78
|
+
for batch_id in unique_ids:
|
|
79
|
+
data = client.get_video_url(batch_id)
|
|
80
|
+
dest = tmp / f"{batch_id}.mp4"
|
|
81
|
+
_download_video(data["url"], dest)
|
|
82
|
+
video_paths[batch_id] = dest
|
|
83
|
+
|
|
84
|
+
# Build segments
|
|
85
|
+
parts: list[str] = []
|
|
86
|
+
console.print(f"\nProcessing {len(clips)} clip(s)…")
|
|
87
|
+
|
|
88
|
+
for i, clip in enumerate(clips, 1):
|
|
89
|
+
participant = clip["participant"]
|
|
90
|
+
country = clip["country"]
|
|
91
|
+
topic = clip["topic"]
|
|
92
|
+
src = str(video_paths[clip["batch_id"]])
|
|
93
|
+
|
|
94
|
+
console.print(f" [{i}/{len(clips)}] {participant} — {topic}")
|
|
95
|
+
|
|
96
|
+
title_dst = str(tmp / f"_title_{i:02d}.mp4")
|
|
97
|
+
clip_dst = str(tmp / f"_clip_{i:02d}.mp4")
|
|
98
|
+
|
|
99
|
+
make_title_card(participant, country, topic, title_dst)
|
|
100
|
+
extract_clip(src, clip["start"], clip["end"], clip_dst)
|
|
101
|
+
|
|
102
|
+
parts.extend([title_dst, clip_dst])
|
|
103
|
+
|
|
104
|
+
console.print("\nConcatenating…")
|
|
105
|
+
concatenate(parts, str(output))
|
|
106
|
+
|
|
107
|
+
size_mb = os.path.getsize(output) / (1024 * 1024)
|
|
108
|
+
console.print(f"\n[green]Done![/green] {output} ({size_mb:.1f} MB)")
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
|
|
7
|
+
from ..client import PodojoClient
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(help="Manage transcripts")
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@app.command("list")
|
|
14
|
+
def list_transcripts(project: str = typer.Argument(help="Project name")):
|
|
15
|
+
"""List transcripts for a project."""
|
|
16
|
+
client = PodojoClient()
|
|
17
|
+
data = client.list_transcripts(project)
|
|
18
|
+
|
|
19
|
+
table = Table(title=f"Transcripts — {project} ({data['total']} total)")
|
|
20
|
+
table.add_column("Batch ID")
|
|
21
|
+
table.add_column("Name")
|
|
22
|
+
|
|
23
|
+
for t in data["interviews"]:
|
|
24
|
+
table.add_row(t.get("batch_id", ""), t.get("batch_name", ""))
|
|
25
|
+
|
|
26
|
+
console.print(table)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.command("download")
|
|
30
|
+
def download_transcript(
|
|
31
|
+
project: str = typer.Argument(help="Project name"),
|
|
32
|
+
batch_id: str = typer.Argument(help="Batch ID"),
|
|
33
|
+
output: Path = typer.Option(None, "-o", "--output", help="Output file path"),
|
|
34
|
+
):
|
|
35
|
+
"""Download a transcript."""
|
|
36
|
+
client = PodojoClient()
|
|
37
|
+
text = client.download_transcript(project, batch_id)
|
|
38
|
+
|
|
39
|
+
if output:
|
|
40
|
+
output.write_text(text)
|
|
41
|
+
console.print(f"Saved to {output}")
|
|
42
|
+
else:
|
|
43
|
+
console.print(text)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import typer
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.progress import Progress
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from ..client import PodojoClient
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(help="Manage videos")
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.command("list")
|
|
16
|
+
def list_videos(project: str = typer.Argument(help="Project name")):
|
|
17
|
+
"""List videos for a project."""
|
|
18
|
+
client = PodojoClient()
|
|
19
|
+
data = client.list_videos(project)
|
|
20
|
+
|
|
21
|
+
table = Table(title=f"Videos — {project} ({data['total']} total)")
|
|
22
|
+
table.add_column("Batch ID")
|
|
23
|
+
table.add_column("Name")
|
|
24
|
+
table.add_column("Duration (min)")
|
|
25
|
+
|
|
26
|
+
for v in data["videos"]:
|
|
27
|
+
table.add_row(
|
|
28
|
+
v.get("batch_id", ""),
|
|
29
|
+
v.get("batch_name", ""),
|
|
30
|
+
str(v.get("duration_minutes", "")),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
console.print(table)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.command("download")
|
|
37
|
+
def download_video(
|
|
38
|
+
batch_id: str = typer.Argument(help="Batch ID"),
|
|
39
|
+
output: Path = typer.Option(None, "-o", "--output", help="Output file path"),
|
|
40
|
+
):
|
|
41
|
+
"""Download a video file."""
|
|
42
|
+
client = PodojoClient()
|
|
43
|
+
data = client.get_video_url(batch_id)
|
|
44
|
+
url = data["url"]
|
|
45
|
+
|
|
46
|
+
filename = output or Path(data.get("filename", f"{batch_id}.mp4"))
|
|
47
|
+
|
|
48
|
+
with httpx.stream("GET", url) as r:
|
|
49
|
+
r.raise_for_status()
|
|
50
|
+
total = int(r.headers.get("content-length", 0))
|
|
51
|
+
with open(filename, "wb") as f, Progress() as progress:
|
|
52
|
+
task = progress.add_task("Downloading...", total=total)
|
|
53
|
+
for chunk in r.iter_bytes(chunk_size=8192):
|
|
54
|
+
f.write(chunk)
|
|
55
|
+
progress.update(task, advance=len(chunk))
|
|
56
|
+
|
|
57
|
+
console.print(f"Saved to {filename}")
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import tomllib
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
CONFIG_PATH = Path.home() / ".podojo.toml"
|
|
8
|
+
DEFAULT_BASE_URL = "https://podojo-fastapi-mcp.onrender.com"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load_config() -> dict:
|
|
12
|
+
config = {}
|
|
13
|
+
if CONFIG_PATH.exists():
|
|
14
|
+
config = tomllib.loads(CONFIG_PATH.read_text())
|
|
15
|
+
config.setdefault("base_url", os.getenv("PODOJO_BASE_URL", DEFAULT_BASE_URL))
|
|
16
|
+
config.setdefault("api_key", os.getenv("PODOJO_API_KEY", ""))
|
|
17
|
+
return config
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def save_config(api_key: str):
|
|
21
|
+
config = {}
|
|
22
|
+
if CONFIG_PATH.exists():
|
|
23
|
+
config = tomllib.loads(CONFIG_PATH.read_text())
|
|
24
|
+
config["api_key"] = api_key
|
|
25
|
+
config.setdefault("base_url", DEFAULT_BASE_URL)
|
|
26
|
+
lines = "\n".join(f'{k} = "{v}"' for k, v in config.items())
|
|
27
|
+
CONFIG_PATH.write_text(lines + "\n")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def clear_api_key():
|
|
31
|
+
if not CONFIG_PATH.exists():
|
|
32
|
+
return
|
|
33
|
+
config = tomllib.loads(CONFIG_PATH.read_text())
|
|
34
|
+
config.pop("api_key", None)
|
|
35
|
+
if config:
|
|
36
|
+
lines = "\n".join(f'{k} = "{v}"' for k, v in config.items())
|
|
37
|
+
CONFIG_PATH.write_text(lines + "\n")
|
|
38
|
+
else:
|
|
39
|
+
CONFIG_PATH.unlink()
|
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""List files in a Google Drive folder."""
|
|
2
|
+
|
|
3
|
+
from .upload import get_drive_service
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def list_files(folder_id: str) -> list[dict]:
|
|
7
|
+
"""Return a list of files in the given folder (id, name, mimeType, webViewLink)."""
|
|
8
|
+
drive = get_drive_service()
|
|
9
|
+
|
|
10
|
+
results = (
|
|
11
|
+
drive.files()
|
|
12
|
+
.list(
|
|
13
|
+
q=f"'{folder_id}' in parents and trashed = false",
|
|
14
|
+
fields="files(id, name, mimeType, webViewLink)",
|
|
15
|
+
supportsAllDrives=True,
|
|
16
|
+
includeItemsFromAllDrives=True,
|
|
17
|
+
)
|
|
18
|
+
.execute()
|
|
19
|
+
)
|
|
20
|
+
return results.get("files", [])
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Upload a markdown file to Google Drive as a Google Doc."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from google.oauth2.service_account import Credentials
|
|
7
|
+
from googleapiclient.discovery import build
|
|
8
|
+
from googleapiclient.http import MediaFileUpload
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
CREDENTIALS_FILENAME = "service-account.json"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_drive_service():
|
|
15
|
+
credentials_path = Path.cwd() / CREDENTIALS_FILENAME
|
|
16
|
+
if not credentials_path.exists():
|
|
17
|
+
raise FileNotFoundError(f"{CREDENTIALS_FILENAME} not found in current directory")
|
|
18
|
+
creds = Credentials.from_service_account_file(
|
|
19
|
+
str(credentials_path),
|
|
20
|
+
scopes=["https://www.googleapis.com/auth/drive"],
|
|
21
|
+
)
|
|
22
|
+
return build("drive", "v3", credentials=creds)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def upload_md_as_doc(
|
|
26
|
+
md_file: str,
|
|
27
|
+
folder_id: str,
|
|
28
|
+
title: str | None = None,
|
|
29
|
+
) -> tuple[str, str]:
|
|
30
|
+
drive = get_drive_service()
|
|
31
|
+
|
|
32
|
+
if not title:
|
|
33
|
+
title = os.path.splitext(os.path.basename(md_file))[0]
|
|
34
|
+
|
|
35
|
+
parent = folder_id
|
|
36
|
+
|
|
37
|
+
media = MediaFileUpload(md_file, mimetype="text/markdown")
|
|
38
|
+
file_metadata = {
|
|
39
|
+
"name": title,
|
|
40
|
+
"mimeType": "application/vnd.google-apps.document",
|
|
41
|
+
"parents": [parent],
|
|
42
|
+
}
|
|
43
|
+
result = (
|
|
44
|
+
drive.files()
|
|
45
|
+
.create(
|
|
46
|
+
body=file_metadata,
|
|
47
|
+
media_body=media,
|
|
48
|
+
fields="id",
|
|
49
|
+
supportsAllDrives=True,
|
|
50
|
+
)
|
|
51
|
+
.execute()
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
doc_id = result["id"]
|
|
55
|
+
url = f"https://docs.google.com/document/d/{doc_id}/edit"
|
|
56
|
+
return doc_id, url
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from .commands import auth, gdrive, projects, showreel, transcripts, videos
|
|
4
|
+
|
|
5
|
+
app = typer.Typer(
|
|
6
|
+
name="podojo",
|
|
7
|
+
help="CLI for the Podojo user research platform",
|
|
8
|
+
no_args_is_help=True,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
app.add_typer(auth.app, name="auth")
|
|
12
|
+
app.add_typer(projects.app, name="projects")
|
|
13
|
+
app.add_typer(transcripts.app, name="transcripts")
|
|
14
|
+
app.add_typer(videos.app, name="videos")
|
|
15
|
+
app.add_typer(showreel.app, name="showreel")
|
|
16
|
+
app.add_typer(gdrive.app, name="gdrive")
|
|
17
|
+
|
|
18
|
+
if __name__ == "__main__":
|
|
19
|
+
app()
|
|
File without changes
|