Skip to main content

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.

Metadata

Release files for yb 0.1.20

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for yb 0.1.20
File Size Uploaded
yb-0.1.20.tar.gz 112.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for yb 0.1.20
File Interpreter ABI Platform
yb-0.1.20-py3-none-any.whl Python 3 none any Details

Total release size: 213.8 kB

Release files / yb-0.1.20.tar.gz

Download URL yb-0.1.20.tar.gz
Size 112.1 kB
Tags Source
SHA-256 checksum
How to use checksums
79fd0c86897347cec6fd8636a491a6a991707f806211b348700161f4299ec84a
BLAKE2b-256 checksum
How to use checksums
a5f817b899a86edb51d780e3aa114f34332108f1a4f21b0f3bec64f8ef7c37c6
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.1 {"installer":{"name":"uv","version":"0.13.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / yb-0.1.20-py3-none-any.whl

Download URL yb-0.1.20-py3-none-any.whl
Size 101.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
d2e1b23df209f846ad75c747308e8230673f00d79c3789f9edc14bcbab204ca6
BLAKE2b-256 checksum
How to use checksums
20fd93c361e51f1a7c57a46e32e4aafddbf88913275bd01d34e0b2a1fd1bd94b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.1 {"installer":{"name":"uv","version":"0.13.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release history Release notifications | RSS feed

This release

0.1.20 This release

2 release files

0.1.19

2 release files

0.1.18

2 release files

0.1.17

2 release files

0.1.16

2 release files

0.1.15

2 release files

0.1.14

2 release files

0.1.13

2 release files

0.1.12

2 release files

0.1.11

2 release files

0.1.10

2 release files

0.1.9

2 release files

0.1.8

2 release files

0.1.7

2 release files

0.1.6

2 release files

0.1.5

2 release files

0.1.4

2 release files

0.1.3

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.0.3

2 release files

0.0.2

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page