外出先から自宅の Mac で動画をダウンロードしたいのに、ローカルネットワークの外だと Mac に触れない。そこで Telegram Bot を経由して Mac 上の処理を動かす形にしました。スマホから URL を貼るだけで済むので、外出先でも気軽に使えます。

構成

構成はこんな感じです。ハンドラとコマンド実行ロジックを分けて、あとから機能を足しやすくしています。

bot/
├── main.py           # エントリポイント・ハンドラ登録
├── config.py         # 環境変数・設定
├── handlers/
│   ├── dl_handler.py    # /dl コマンド
│   ├── help_handler.py # /help
│   ├── status_handler.py # /status
│   └── ...
└── commands/
    ├── dl_dispatch.py   # URL 判定とプラットフォーム振り分け
    ├── dl_youtube.py   # yt-dlp による YouTube 動画・音声
    ├── dl_twitter.py   # Twitter 動画
    ├── dl_common.py    # シグナルハンドラ・サブプロセス終了
    └── dl_state.py     # 実行中タスクの状態管理

動画ダウンロード

/dl-s で音声のみ、-l でライブ配信を指定できます。URL に応じて YouTube か Twitter に振り分ける処理は dl_dispatch.py で行っています。

def download(url, audio_only, live_stream) -> tuple[bool, str]:
    if dl_youtube.is_youtube_url(url):
        if audio_only:
            return dl_youtube.download_audio(url, live_stream=live_stream)
        return dl_youtube.download_video(url, live_stream=live_stream)
    if dl_twitter.is_twitter_url(url):
        return dl_twitter.download_video(url)
    return False, "対応していない URL です。"

yt-dlp はブロッキングなので、asyncio.to_thread で別スレッドに任せて、ダウンロード中も他のリクエストを処理できるようにしました。

# ブロッキング処理を別スレッドで実行
download_id = register_download(url, media_type, live_stream, user_id)
try:
    success, message = await asyncio.to_thread(
        download, url, audio_only, live_stream
    )
    await update.message.reply_text(message)
finally:
    unregister_download(download_id)

動画は最高画質・音声をマージして mp4 で出す設定です。ライブ配信の場合は live_from_start で配信終了まで録画します。

ydl_opts = {
    "format": "bestvideo+bestaudio/best",
    "merge_output_format": "mp4",
    "outtmpl": str(DL_OUTPUT_DIR / "%(id)s.%(ext)s"),
    "restrictfilenames": True,  # パストラバーサル対策
    "quiet": True,
    "no_warnings": True,
}
if live_stream:
    ydl_opts["live_from_start"] = True

ファイル名は restrictfilenames でサニタイズしてパストラバーサル対策を入れました。Bot 終了時(Ctrl+C など)に実行中の yt-dlp をちゃんと止めるため、シグナルハンドラと atexit を登録しています。登録済みのサブプロセスを一括で terminate し、5 秒待っても終わらなければ kill する形にしました。

# Ctrl+C 等で Bot 終了時にサブプロセスを確実に終了
def _terminate_active_subprocesses():
    for proc in _active_ytdlp_processes:
        if proc.poll() is None:
            proc.terminate()
            try:
                proc.wait(timeout=5)
            except subprocess.TimeoutExpired:
                proc.kill()

signal.signal(signal.SIGINT, _signal_handler)
atexit.register(_terminate_active_subprocesses)

セキュリティとコマンド

ユーザー ID のホワイトリスト(ALLOWED_USER_IDS)で許可した人だけがコマンドを打てるようにしています。.env でカンマ区切りで指定し、/whoami で自分の ID を確認してから追加する運用にしました。

# 許可するユーザー ID のリスト(カンマ区切り)
_raw_ids = os.getenv("ALLOWED_USER_IDS", "").strip()
ALLOWED_USER_IDS = [int(uid.strip()) for uid in _raw_ids.split(",") if uid.strip()]

YouTube と Twitter の URL だけ受け付け、他サイトは弾くようにしました。技術スタックは python-telegram-botyt-dlppython-dotenv です。

使えるコマンドは /dl(動画・音声・ライブ)、/status/help/whoami です。Phase 1 として動く Bot ができたので、今後は Mac 上だからこそできる処理(ローカル LLM、音声の文字起こし、画像・動画の解析など)を足していく予定です。