尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證製作者的介紹僅有上面的一句話。 更多詳情請閱讀儲存庫 README。 儲存庫 README ↗
正在載入完整 README。
檢視完整 README ↗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",
)
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.
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.
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.
from yb.download import (
download_youtube_video,
download_youtube_audio,
youtube_video_info,
search_youtube,
)
youtube_video_info(url) # metadata only, no download
search_youtube(
"song name", limit=5
) # -> [SearchHit(id, title, duration, channel, url, view_count)]
search_youtube(
"house dance footwork", limit=20, creative_commons=True, min_s=45, max_s=900
) # YouTube's Creative Commons filter + a duration window
download_audio_bounded(
url, download_dir=tmp, max_bytes=..., max_duration_s=..., validate_target=...
) # mp3 + caps + copyright notice, for services (see below)
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_subtitles(url, download_dir="~/clips") # subtitles only, no media
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")
Links from untrusted users. download_audio_bounded is the one to call from a service: validate_target (your SSRF policy; the default only requires http(s)) is applied to every URL yt-dlp requests, including the media a page points at and where a redirect chain ends (the hops in between are requested blind, their content never returned); the byte cap and the timeout_s wall-clock budget hold even when the size is unknown; formats needing an external downloader are never selected; no cookies are sent unless you pass cookies_file=. It raises ValueError subclasses for every refusal (AudioTargetError, AudioLimitError, AudioSourceError), RateLimitedError when the source pushes back, and ImportError (with the install command) when yt-dlp is missing.
Subtitles are best-effort (download_youtube_video, download_youtube_audio, download_youtube_playlist). YouTube rate-limits its captions endpoint before the video one, so when only the subtitle fetch fails the video is downloaded without them: result.subtitles_error says why, a warning is issued, and download_youtube_subtitles fetches them later. The retry is in place (same item, same name, nothing extracted twice), in a playlist too. Pass on_subtitle_error="raise" when the subtitles are not optional (a playlist then raises PlaylistSubtitleError, carrying the items that did download).
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. yt-dlp rewrites the file on exit, so a
file that is not writable (read-only mount) is handed over as a private temporary
copy automatically; set YB_COOKIES_PRIVATE_COPY=1 to always do that (parallel
workers sharing one file; every worker must set it). Refreshed cookies are then
not saved back.
When cookies stop working (servers). YouTube rotates the cookies of a browser session that stays open, and from then on every request gets "Sign in to confirm you're not a bot". yb check --online checks that the cookies file holds a logged-in session, reads one public video with it (on a copy, so the check never rewrites the file), and says when it was rotated or refused. Export cookies that do not rotate: open a private / incognito window, log in to YouTube, go to https://www.youtube.com/robots.txt in that same tab, export the youtube.com cookies (Netscape format), then close the private window right away and never reopen that session (yt-dlp's cookie guide).
HTTP 403 on the video data: PO tokens. Some servers pass the bot check (metadata loads) but get HTTP 403 on the media, and automatic captions are missing "because a PO token was not provided". yt-dlp then needs a PO-token provider: pip install 'yb[pot]' (the bgutil-ytdlp-pot-provider plugin, picked up by yt-dlp automatically) and run its token server, a system dependency: docker run -d -p 127.0.0.1:4416:4416 --init brainicism/bgutil-ytdlp-pot-provider (or its Node script; keep it bound to localhost). yb check says whether the plugin is installed and yb check --online whether its server answers. While the media is refused, the subtitles alone may still come down: download_youtube_subtitles(url).
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).
A long playlist, politely. download_youtube_playlist takes pace= ("polite" sleeps 20-60 s between items and 1 s between requests, and caps the rate at 4 MB/s; or pass a Pace(...)), download_archive= (finished ids are skipped on re-run), playlist_items="1-42" and title_reject= for selection, and item_folders=True for one 001_<id>/ folder per item with the media and its sidecars side by side. For audio only, keep the source stream with fmt="bestaudio/best", merge_to=None:
from yb.download import download_youtube_playlist
download_youtube_playlist(
url,
download_dir="~/tapes",
fmt="bestaudio/best", # audio only, not re-encoded (.m4a)
merge_to=None,
pace="polite",
download_archive="~/tapes/archive.txt", # re-run to resume
playlist_items="1-42",
item_folders=True, # ~/tapes/001_<id>/<id>.m4a, .info.json, ...
write_description=True,
write_thumbnail=True,
write_subtitles=True,
embed_metadata=True,
)
When YouTube answers HTTP 429 or "confirm you're not a bot", yb raises yb.download.RateLimitedError with a backoff hint (wait, then retry with pace="polite", an archive, cookies and fewer items per run). pace also works on download_youtube_video and download_youtube_audio; the default None changes nothing, and raw extra_opts still win.
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
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.
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
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.
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:
normalize=False to opt out.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.
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.
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>'.
Several channels (profiles). Default stays your first account. For another channel add a section under "profiles" in ~/.config/yb/config.json, consent once with yb auth --profile NAME, check with yb whoami --profile NAME (prints the channel the token uploads to), then publish with profile="NAME" or YB_PROFILE=NAME. Each profile has its own token (youtube_token.NAME.json) and settings; an optional channel_id makes publish_* refuse a token for the wrong channel, and an optional login_hint (the account's email) makes the consent page preselect the right Google account. A redirect pasted without --profile is matched to the profile that started it, and yb says which.
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.
| 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.