yb

Streamline media publishing: prepare a piece of media once, publish it anywhere — to YouTube (upload + edit) or as a podcast (show notes, chapters, RSS) — plus a download module for pulling videos back down.

import yb

# Download a video (-> ~/Downloads, named "Title (id).mp4")
r = yb.download_youtube_video("https://youtu.be/PRa9ciOe-us")

# One call: transcribe, write title/description/keywords, detect chapters,
# render a thumbnail, and upload to YouTube (unlisted) with captions.
yb.prepare_and_publish(
    r.path,
    language="English",
    language_code="en",
    audio_language_code="en",
    privacy_status="unlisted",
)

Why

yb separates what you publish from where you publish it. A media file is turned into a platform-neutral PublicationContent — title, description, keywords, chapters, captions, thumbnail — and thin adapters map that onto each destination’s API. The media work itself (transcription, chapter detection, thumbnails, dubbing/translation) lives in the mixing package; yb is the publication layer on top.

Install

pip install yb                 # core (needs `mixing`)
pip install 'yb[youtube]'      # YouTube upload/edit (Google API client)
pip install 'yb[download]'     # yt-dlp downloader
pip install 'yb[podcast]'      # podcast chapters (ID3/PSC) + RSS
pip install 'yb[llm]'          # LLM metadata/chapter copy (via `aix`)
pip install 'yb[all]'          # everything

Everything heavy is an optional extra, and import yb never fails for a missing one — the error only surfaces when you actually call that feature. You also need ffmpeg on PATH for media work, and an ELEVENLABS_API_KEY for transcription.

System dependencies

  • ffmpeg on PATH, for media work.

  • Deno, for downloading from YouTube. YouTube makes yt-dlp solve a JavaScript challenge; without a runtime, downloads fail with No video formats found!. yb[download] depends on yt-dlp[default], which brings the solver scripts; the runtime you install yourself: curl -fsSL https://deno.land/install.sh | sh (or brew install deno, or pip install 'yt-dlp[deno]' into the same environment). Deno is the runtime yt-dlp enables by default; to use another (node, bun), enable it yourself with extra_opts={'js_runtimes': {'node': {}}}. The installer puts Deno in ~/.deno/bin and adds it to PATH only for interactive shells; yb also looks there (and next to the running Python), and passes yt-dlp the path, so cron jobs and services work too. yb check also runs deno --version and reports a Deno older than yt-dlp’s minimum (read from yt-dlp itself) with the deno upgrade command. To put it on PATH anyway: export PATH="$HOME/.deno/bin:$PATH" in ~/.profile. See yt-dlp’s EJS notes.

Run yb check (or yb.download.check_requirements()) to see what is missing and how to install it. The first download in a process runs the same check and warns; it never refuses to try.

Common tasks

Download

from yb.download import (
    download_youtube_video,
    download_youtube_audio,
    youtube_video_info,
)

youtube_video_info(url)  # metadata only, no download
download_youtube_video(url)  # best quality -> ~/Downloads/"Title (id).mp4"
download_youtube_video(
    url,
    download_dir="~/clips",
    write_info_json=True,
    write_subtitles=True,
    subtitle_langs=("en", "fr"),
)

download_youtube_audio(url)  # audio only, source format -> "Title (id).webm"
download_youtube_audio(url, audio_format="mp3")  # ...converted (needs ffmpeg)
download_youtube_audio(url, audio_format="mp3", bitrate="320k")

The destination defaults to $YB_DOWNLOAD_DIR (else ~/Downloads). Every knob — format, filename template, which sidecar metadata to also save — is overridable, and any raw yt-dlp option can be passed via extra_opts=.

Cookies (headless servers). YouTube bot-blocks anonymous downloads from many servers. Export a Netscape-format cookies file from a logged-in browser elsewhere and point yb at it with YB_COOKIES_FILE=/path/cookies.txt (or just put it at ~/.config/yb/youtube-cookies.txt); every download and info call then uses it, with no extra argument. Pass cookies_file= to choose one per call (False turns the default off). An explicit cookies_from_browser= or a cookiefile / cookiesfrombrowser in extra_opts= always wins. With no file and no env var, nothing changes. Never commit the file.

Audio format. download_youtube_audio keeps whatever the source offers by default (YouTube’s best audio is normally Opus in a .webm container). That default needs no ffmpeg and avoids re-encoding one lossy format into another. Pass audio_format= when you need a specific one — it’s a no-op if the download already is that format, so it’s safe to pass unconditionally. If conversion can’t happen (no ffmpeg) it raises, and the download is always left on disk; pass on_error="warn" to fall back to the unconverted file instead.

A time range. To source a sound or a clip without downloading the whole video, pass start= and/or end= to download_youtube_video or download_youtube_audio (seconds, or "mm:ss" / "hh:mm:ss"):

clip = download_youtube_audio(url, start="0:07.9", end="0:08.75")
clip.info["download_range"]  # {"start": 7.9, "end": 8.75}, for provenance

The range is cut by ffmpeg (so it needs one; to use a specific build pass extra_opts={"ffmpeg_location": "/path/to/ffmpeg"}, e.g. when the system ffmpeg lacks what yt-dlp asks of it). Cuts land on keyframes; precise_cuts=True re-encodes for frame-accurate bounds. With the default filename template the range goes in the name (Title (id) [7.9-8.75].webm), so a clip never collides with the full download (a precise_cuts clip is named apart from a keyframe cut too).

The same conversion is available standalone for files you already have:

from yb.audio_convert import convert_audio

convert_audio("talk.webm", "mp3")  # -> talk.mp3
convert_audio("talk.webm", "wav")  # lossless: bitrate not applied

Publish to YouTube

from yb.youtube import prepare_and_publish, VideoMetadata, publish_video, CaptionTrack

# High level: prepare assets from the media file, then upload.
result = prepare_and_publish(
    "promo.fr.mp4",
    language="French",
    language_code="fr",
    audio_language_code="fr",
    privacy_status="unlisted",
)
print(result["url"])

# Lower level: you supply the metadata and captions.
meta = VideoMetadata(
    title="...",
    description="...",
    tags=[...],
    default_language="en",
    default_audio_language="en",
)
publish_video(
    "promo.en.mp4",
    meta,
    privacy_status="unlisted",
    captions=[CaptionTrack("promo.en.srt", "en", "English")],
    thumbnail="thumb.jpg",
)

If captions, the thumbnail or the playlist fail after the upload (a 403 quotaExceeded, say), PartialPublish is raised with the new video’s id in err.result. Call again with resume=err.result to finish without uploading a second copy.

PartialPublish is a RuntimeError and replaces the original error, which stays reachable as err.__cause__. Code that caught googleapiclient.errors.HttpError around a publish call no longer catches a failure that happens after the upload: catch PartialPublish too (or inspect err.__cause__).

Chapters are on by default: when the media is long enough to host them (YouTube requires ≥3 markers, each ≥10 s, first at 0:00), they are detected from the transcript and embedded in the description so YouTube renders interactive chapters. Short clips simply get none.

Edit an already-published video

from yb.youtube import update_video_fields, upsert_caption, set_chapters, set_thumbnail
from mixing.chapters import Chapter

update_video_fields("VIDEO_ID", title="New title", tags=["a", "b"])
upsert_caption(
    "VIDEO_ID", "subs.fr.srt", language="fr", name="Français"
)  # replaces same-lang track
set_chapters("VIDEO_ID", [Chapter(0, "Intro"), Chapter(45, "Demo")])
set_thumbnail("VIDEO_ID", "thumb.jpg")

Privacy, and replacing a published video with a re-cut (YouTube cannot swap the file):

from yb.youtube import update_video_status, supersede

update_video_status("VIDEO_ID", privacy_status="public")  # other status fields kept
supersede(
    "OLD_ID", "NEW_ID", new_privacy="public"
)  # new takes old's playlist spots; old goes private

supersede does not change the new video’s privacy unless you pass new_privacy=, and uploads are unlisted by default. So superseding a public video with a fresh upload leaves the public with neither: the old one goes private and the re-cut stays unlisted. When that happens, supersede emits a VisibilityDropWarning before it changes anything. Pass new_privacy="public" to publish the re-cut, or make the warning an error to refuse such calls:

import warnings
from yb.youtube import VisibilityDropWarning

warnings.simplefilter(
    "error", VisibilityDropWarning
)  # supersede now raises, changing nothing

Read live stats & metadata

from yb.youtube import video_metadata, FIELD_GROUPS

video_metadata(
    "VIDEO_ID", group="engagement"
)  # dict: views, likes, dislikes, comments, ...
print(
    video_metadata("VIDEO_ID", group="engagement", as_table=True)
)  # readable ASCII table
video_metadata(
    "VIDEO_ID", fields=["title", "views", "likes"]
)  # pick & order exact fields
print(
    video_metadata(["ID1", "ID2"], group="engagement", as_table=True)
)  # compare videos, one row each

One request returns a flat, typed view of a video’s live numbers plus content and status details. With no group/fields you get every field; fields overrides group. Named groups (FIELD_GROUPS): engagement, overview, content, status, identity. dislikes is returned only to the video’s owner (else None); watch-time/retention/shares live in the YouTube Analytics API, not here.

Publish a song as a music video

You have a song and its cover; YouTube wants a video. yb.music renders one (through muvid.visualize) and publishes it through the same machinery as everything else. Needs pip install 'yb[music]' (pulls muvid, which needs ffmpeg); uploading also needs yb[youtube].

from yb.music import prepare_music_video, publish_music, publish_folder

# Simplest: the cover on a 16:9 canvas, held for the song's length.
mv = prepare_music_video("song.wav", image="cover.png")
mv.video, mv.thumbnail  # -> song.mp4, song.thumb.jpg

# Render and upload in one call.
publish_music("song.wav", image="cover.png", privacy_status="unlisted")

A whole folder → an album. Drop (song.wav, song.jpeg) pairs in a folder — the filename is the title, covers match by name — and publish them all, each song rendered with the next visual in a rotation:

# Test the first song (unlisted) before committing the set…
publish_folder("~/album", limit=1)
# …then do the rest, collected into one playlist.
publish_folder("~/album", playlist="My Album")

By default the rotation is waves → cqt → spectrum → bars → scope (override with cycle=), so a set varies method to method yet reads as one release — reinforced by a consistent teal accent and one loudness target across every video. prepare_folder(...) renders without uploading, for review first.

Pick a single visual with visual=: "still" (default when there’s an image), "ken_burns" (a slow pan/zoom), or an audio-reactive one — "cqt" (pitch-aligned bars, the most musical), "bars", "spectrum", "waves", "scope". All the reactive ones are pure ffmpeg. You can also pass your own callable (see muvid.visualize.register_visual).

What you get for free, because it’s what makes a set of songs feel like a release:

  • 16:9, never pillarboxed. Square or portrait art is composed onto a 1080p canvas filled with a blurred, darkened copy of itself — not black bars.

  • Loudness-normalized to −14 LUFS (EBU R128, two-pass), so an album plays at one level. normalize=False to opt out.

  • A thumbnail derived from the same composition (1280×720, under YouTube’s 2 MiB limit), used as the video’s thumbnail by default.

  • Music category, and a video exactly as long as the song.

Check what you made before you ship it:

from muvid.visualize import verify_video, report

print(report(verify_video("song.mp4", audio="song.wav", thumbnail="song.thumb.jpg")))

See the music2video skill for the visualizer menu and the full quality checklist.

Publish a podcast episode

from yb.podcast import (
    prepare_podcast_episode,
    PodcastChannel,
    EpisodeFeedItem,
    build_feed,
)

ep = prepare_podcast_episode(
    "episode.mp3", "out/", cover_image="cover.jpg", make_cover_video=True
)
# -> out/episode.mp3 (with ID3 chapters), .shownotes.txt, .psc.xml,
#    .chapters.json, and .cover.mp4

# Assemble an RSS feed (delivery to Spotify/Apple is via your podcast host).

A podcast episode and a YouTube video are built from the same PublicationContent: an episode “could just be audio with a cover image” (or a Ken Burns video), which is exactly what prepare_podcast_episode can render.

Setup (YouTube OAuth)

YouTube upload needs a Google OAuth Desktop client (one-time). gcloud is not required — the console path works. See the yb-setup skill for the full walkthrough (enable the API, create the OAuth client, run consent, test). Point yb at the client JSON via $YOUTUBE_CLIENT_SECRETS_FILE.

Publish the OAuth consent screen (Audience → Publish app) before consenting: tokens minted while it is in Testing expire after 7 days. With no browser or terminal on the machine (a phone-driven session), consent by paste-back: yb auth, open the URL anywhere, then yb auth --paste '<redirected URL>'.

Note: until your Google Cloud project passes YouTube’s one-time API compliance audit, uploads may be forced to private even when you request unlisted/public.

Layout

Module

Role

Extra

yb.content

platform-neutral content prep (metadata, chapters, thumbnail)

core

yb.music

songs → music videos, published as an album

music (+youtube to upload)

yb.youtube

upload, edit + read stats via YouTube Data API v3

youtube

yb.podcast

show notes, ID3/PSC chapters, cover video, RSS

podcast (+music for cover video)

yb.download

yt-dlp downloader (video, audio-only, playlists, metadata)

download

yb.audio_convert

audio format conversion via ffmpeg

core (needs ffmpeg)

The audio→video rendering itself lives in muvid.visualize (canvas, visual strategies, loudness, verification); yb.music is the publish-facing facade over it, and the music extra pulls muvid.

This documentation as a single file: yb.md (Markdown, for agents).

built 2026-10-14 12:40 UTC from 9062594 (main) · yb 0.1.19 · about this build