When posting videos on YouTube, are you only finishing in Japanese? This time, I created a tool using Python to batch localize (translate) YouTube video titles and descriptions into multiple languages, and I will record a retrospective on that.

Background of the Localization Tool

YouTube has a “title translation feature,” but manually entering each one is very time-consuming. Therefore, I created a script using Python to automatically translate and register titles and descriptions from Japanese into various languages around the world.

Rapid Development with Vibe Coding

This development style is what you might call vibe coding. Since it’s infrastructure-related, there isn’t much development involved, right? (laughs)

import os
import json
import logging
from typing import Dict, List, Tuple

from dotenv import load_dotenv
from openai import OpenAI
from googleapiclient.discovery import build
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.errors import HttpError

# =========================
# Settings
# =========================

BASE_LANG = "ja"  # Original language is Japanese

# Supported languages (YouTube language codes → names and audience descriptions)
LANG_CONFIG: Dict[str, Dict[str, str]] = {
    "en":      {"name": "US English",              "audience": "viewers in North America and other English-speaking countries"},
    "es":      {"name": "European Spanish",        "audience": "viewers in Spain"},
    "es-419":  {"name": "Latin American Spanish",  "audience": "viewers in Mexico and other Latin American countries"},
    "fr":      {"name": "French",                  "audience": "viewers in France and other French-speaking regions"},
    "de":      {"name": "German",                  "audience": "viewers in Germany, Austria, and Switzerland"},
    "pt-BR":   {"name": "Brazilian Portuguese",    "audience": "viewers in Brazil"},
    "ru":      {"name": "Russian",                 "audience": "viewers in Russia and neighboring countries"},
    "zh-Hans": {"name": "Simplified Chinese",      "audience": "viewers in Mainland China"},
    "zh-Hant": {"name": "Traditional Chinese",     "audience": "viewers in Taiwan, Hong Kong, and other Traditional Chinese regions"},
    "ar":      {"name": "Arabic",                  "audience": "viewers in Arabic-speaking countries"},
    "id":      {"name": "Indonesian",              "audience": "viewers in Indonesia"},
    "ko":      {"name": "Korean",                  "audience": "viewers in South Korea"},
}

# YouTube Data API scopes
SCOPES = ["https://www.googleapis.com/auth/youtube.force-ssl"]

# =========================
# Logger settings
# =========================

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
)
logger = logging.getLogger(__name__)

# Load environment variables from .env if present
load_dotenv()

# =========================
# Client generation
# =========================

def get_youtube_client():
    """Returns the YouTube Data API client."""
    logger.info("Starting YouTube API authentication...")
    flow = InstalledAppFlow.from_client_secrets_file(
        "client_secret.json", SCOPES
    )
    try:
        creds = flow.run_local_server(port=0, timeout=120)
    except KeyboardInterrupt:
        logger.info("User interrupted authentication. Exiting.")
        raise SystemExit(1)
    youtube = build("youtube", "v3", credentials=creds)
    logger.info("YouTube API client obtained.")
    return youtube

def get_openai_client() -> OpenAI:
    """Returns the OpenAI client."""
    api_key = os.environ.get("OPENAI_API_KEY")
    if not api_key:
        logger.error("Environment variable OPENAI_API_KEY is not set.")
        raise RuntimeError("OPENAI_API_KEY is not set.")
    logger.info("Initialized OpenAI client.")
    return OpenAI(api_key=api_key)

# =========================
# Localization with OpenAI
# =========================

def localize_for_language(
    client: OpenAI,
    base_title: str,
    base_desc: str,
    yt_lang: str,
    lang_meta: Dict[str, str],
) -> Tuple[str, str]:
    """
    Localizes the title and description for one language using OpenAI.
    Returns: (localized_title, localized_description)
    """
    language_name = lang_meta["name"]
    audience = lang_meta["audience"]

    system_prompt = (
    "You are an expert YouTube copywriter and localization specialist.\n"
    "You receive the original Japanese title and description for a YouTube video.\n"
    "Your job is NOT to translate literally, but to localize them so they sound natural, persuasive, and engaging "
    "for the target language and culture.\n"
    "Respect the original intent, topic, and tone (for example: educational, entertaining, serious, casual, etc.), "
    "while optimizing for YouTube viewers in the specified audience region.\n"
    "Avoid clickbait and misleading exaggeration, but make the title and description attractive and clear.\n"
    "You must also suggest appropriate hashtags in the target language (and English if commonly used) that help reach the right audience.\n"
    "Return a JSON object with exactly three fields: 'title', 'description', and 'hashtags'.\n"
    "'hashtags' must be a JSON array of strings, each string being a hashtag including the leading '#'.\n"
    "No extra commentary, no markdown, only the JSON object."
)

    user_prompt = f"""
[Target language]
- Language: {language_name}
- YouTube language code: {yt_lang}
- Audience: {audience}

[Original Japanese title]
{base_title}

[Original Japanese description]
{base_desc}

[Requirements]
- Keep the core meaning and key information of the original Japanese text.
- Adapt expressions and nuance so they feel natural and appealing in the target language and culture.
- Make the title concise and catchy for YouTube (ideally under ~70 characters in this language, if reasonable).
- In the description, use 2–4 short paragraphs for readability.
- Maintain a tone similar to the original (for example: friendly, curious, educational, relaxed, serious, etc.).
- Do NOT put hashtags inside the description.
- Instead, provide 5–12 appropriate hashtags in the 'hashtags' field, as a JSON array of strings.
  - Include topic-specific hashtags (related to the content of the video).
  - Optionally include a few general YouTube or genre-related hashtags (e.g., about the format or category).
- Use only the target language (and English where it is naturally used for hashtags).
"""

    try:
        resp = client.chat.completions.create(
            model="gpt-4.1-mini",  # Change the model as needed
            response_format={"type": "json_object"},
            messages=[
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": user_prompt},
            ],
            temperature=0.8,
        )
    except Exception as e:
        logger.error(f"OpenAI API error: lang={yt_lang}, error={e}")
        raise

    content = resp.choices[0].message.content
    try:
        data = json.loads(content)
        title = data.get("title", "").strip()
        desc = data.get("description", "").strip()
    except json.JSONDecodeError as e:
        logger.error(f"Failed to parse JSON from OpenAI: lang={yt_lang}, error={e}")
        logger.error(f"Response content: {content}")
        raise

    return title, desc

# =========================
# Updating localizations for one video
# =========================

def update_video_localizations(
    youtube,
    oa_client: OpenAI,
    video_id: str,
) -> Tuple[List[str], List[str]]:
    """
    Registers multilingual titles/descriptions for one video.
    Returns: (list of successful languages, list of failed languages)
    """
    logger.info(f"Fetching video information: video_id={video_id}")

    try:
        res = youtube.videos().list(
            part="snippet,localizations",
            id=video_id,
        ).execute()
    except HttpError as e:
        logger.error(f"YouTube API error (videos.list): video_id={video_id}, error={e}")
        return [], list(LANG_CONFIG.keys())

    if not res.get("items"):
        logger.warning(f"Video not found: video_id={video_id}")
        return [], list(LANG_CONFIG.keys())

    video = res["items"][0]
    snippet = video["snippet"]
    localizations = video.get("localizations", {})

    base_title = snippet.get("title", "")
    base_desc = snippet.get("description", "")

    logger.info(f"Original title: {base_title}")
    logger.info(f"Original description: {base_desc[:60]}...")  # Show only the beginning

    # Explicitly set the default language
    snippet["defaultLanguage"] = BASE_LANG

    success_langs: List[str] = []
    failed_langs: List[str] = []

    # Localize for each target language
    for yt_lang, meta in LANG_CONFIG.items():
        if yt_lang == BASE_LANG:
            continue

        logger.info(f"  Starting localization: {yt_lang} ({meta['name']})")

        try:
            loc_title, loc_desc = localize_for_language(
                oa_client, base_title, base_desc, yt_lang, meta
            )
        except Exception:
            logger.error(f"  Localization failed: {yt_lang}")
            failed_langs.append(yt_lang)
            continue

        logger.info(f"  Localization result title [{yt_lang}]: {loc_title}")
        localizations[yt_lang] = {
            "title": loc_title,
            "description": loc_desc,
        }
        success_langs.append(yt_lang)

    # Reflect updates to YouTube
    update_body = {
        "id": video_id,
        "snippet": snippet,
        "localizations": localizations,
    }

    try:
        youtube.videos().update(
            part="snippet,localizations",
            body=update_body,
        ).execute()
        logger.info(f"Updated localizations for video: video_id={video_id}")
    except HttpError as e:
        logger.error(f"YouTube API error (videos.update): video_id={video_id}, error={e}")
        # If the update itself fails, treat this video as a complete failure
        return [], list(LANG_CONFIG.keys())

    return success_langs, failed_langs

# =========================
# Helper: Load video IDs
# =========================

def load_video_ids(path: str = "video_ids.txt") -> List[str]:
    if not os.path.exists(path):
        logger.error(f"Video ID list file not found: {path}")
        raise FileNotFoundError(path)

    ids: List[str] = []
    with open(path, encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if line and not line.startswith("#"):
                ids.append(line)
    logger.info(f"Loaded {len(ids)} video IDs.")
    return ids

# =========================
# Main
# =========================

def main():
    youtube = get_youtube_client()
    oa_client = get_openai_client()
    video_ids = load_video_ids()

    total_videos = len(video_ids)
    total_success = 0
    total_failed = 0

    for idx, vid in enumerate(video_ids, start=1):
        logger.info("=" * 60)
        logger.info(f"[{idx}/{total_videos}] Processing start: video_id={vid}")

        success_langs, failed_langs = update_video_localizations(
            youtube, oa_client, vid
        )

        logger.info(f"[Result] video_id={vid}")
        logger.info(f"  Successful languages: {success_langs}")
        if failed_langs:
            logger.warning(f"  Failed languages: {failed_langs}")

        if success_langs:
            total_success += 1
        else:
            total_failed += 1

    logger.info("=" * 60)
    logger.info("Processing of all videos is complete.")
    logger.info(f"  Successful videos: {total_success}")
    logger.info(f"  Failed videos: {total_failed}")
    logger.info(f"  Total videos: {total_videos}")

if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        logger.info("Detected Ctrl+C, exiting.")
        raise SystemExit(1)

The compatibility between Python’s ecosystem and LLMs is excellent, and I was able to implement the cumbersome API-related processes smoothly. I didn’t realize YouTube had an API! It’s free for this use case via GCP.

What are the effects?

“Will the number of views dramatically increase if I set the title in the local language?”

To be honest, the effect is currently unknown. However, the goal this time was not to chase numbers but to create a state where “users from various cultural backgrounds can access content in their native language.”

By accommodating not only major languages like English and Spanish but also more minor languages, we ensure accessibility to all cultural spheres.

Surprisingly Low API Costs

Some may be concerned about API usage fees (token consumption) when using LLMs for translation.

However, in practice, the API tokens are surprisingly low. Since we are not analyzing the content of the video (audio or visuals), but only translating the “title” and “description” text data, it is very lightweight. To put it another way, if you use DeepL’s free API, it would be free. Although it might result in mechanical translations.

Conclusion

From now on, it seems that how well the content we create can cross borders will become important. Just a feeling. This blog is also automatically pushed to multiple languages…