Source code for yb.youtube.metadata
"""YouTube video metadata ↔ API snippet mapping.
Maps the platform-neutral :class:`yb.content.PublicationContent` onto a
YouTube ``videos.insert`` body, applying YouTube's field limits (title <= 100
chars, description <= 5000, tags combined <= 500) and embedding the chapters
block into the description (YouTube renders chapters from description
timestamps when the first is ``0:00``).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Sequence
from yb.content import PublicationContent, format_chapter_lines
#: A useful subset of YouTube video category ids.
CATEGORY_SCIENCE_TECH = "28"
CATEGORY_EDUCATION = "27"
CATEGORY_PEOPLE_BLOGS = "22"
CATEGORY_ENTERTAINMENT = "24"
CATEGORY_MUSIC = "10"
_TITLE_MAX = 100
_DESC_MAX = 5000
_TAGS_TOTAL_MAX = 500
[docs]
@dataclass
class VideoMetadata:
"""YouTube-specific video metadata.
Attributes:
title: Title (<= 100 chars).
description: Description (<= 5000 chars), chapters already embedded.
tags: Keyword tags (combined length kept under 500 chars).
category_id: YouTube category id (default Science & Technology).
default_language: BCP-47 language of the metadata text.
default_audio_language: BCP-47 language of the audio.
contains_synthetic_media: Declare that the video contains realistic
altered or synthetic content. YouTube's disclosure rule turns on
*realism*, not on whether AI was involved: it applies when a viewer
could mistake the content for a real person, place, or event —
not to stylised artwork or a visualizer. Left unset when ``None``.
"""
title: str
description: str
tags: list[str] = field(default_factory=list)
category_id: str = CATEGORY_SCIENCE_TECH
default_language: str | None = None
default_audio_language: str | None = None
contains_synthetic_media: bool | None = None
def __post_init__(self):
self.title = self.title.strip()[:_TITLE_MAX]
self.description = self.description.strip()[:_DESC_MAX]
self.tags = _cap_tags(self.tags, _TAGS_TOTAL_MAX)
[docs]
@classmethod
def from_content(
cls,
content: PublicationContent,
*,
category_id: str = CATEGORY_SCIENCE_TECH,
with_chapters: bool = True,
) -> "VideoMetadata":
"""Build YouTube metadata from a platform-neutral content bundle.
Embeds the chapters block into the description (when present and
``with_chapters``), so YouTube renders interactive chapters.
"""
description = content.description
if with_chapters and content.chapters:
block = format_chapter_lines(content.chapters)
body = description.rstrip()
description = (
f"{body}\n\nChapters:\n{block}" if body else f"Chapters:\n{block}"
)
return cls(
title=content.title,
description=description,
tags=list(content.keywords),
category_id=category_id,
default_language=content.language,
default_audio_language=content.audio_language,
)
[docs]
def insert_body(self, *, privacy_status: str = "unlisted") -> dict:
"""Build the ``videos.insert`` body (snippet + status)."""
status: dict = {
"privacyStatus": privacy_status,
"selfDeclaredMadeForKids": False,
}
if self.contains_synthetic_media is not None:
status["containsSyntheticMedia"] = self.contains_synthetic_media
return {"snippet": self._snippet(), "status": status}
[docs]
def update_snippet(self) -> dict:
"""Build the snippet for ``videos.update`` (categoryId is required)."""
return self._snippet()
def _snippet(self) -> dict:
snippet: dict = {
"title": self.title,
"description": self.description,
"tags": self.tags,
"categoryId": self.category_id,
}
if self.default_language:
snippet["defaultLanguage"] = self.default_language
if self.default_audio_language:
snippet["defaultAudioLanguage"] = self.default_audio_language
return snippet
def _cap_tags(tags: Sequence[str], total_max: int) -> list[str]:
"""Trim a tag list so the comma-joined length stays under ``total_max``."""
out: list[str] = []
used = 0
for t in tags:
t = t.strip()
if not t:
continue
add = len(t) + (1 if out else 0)
if used + add > total_max:
break
out.append(t)
used += add
return out