有料ポッドキャスト配信を自前で作ったら沼すぎて、最終的に全部捨てた話

配信ステータス:未配信 配信URL:(配信済みになったら記入)

ベース記事:📎 https://zenn.dev/kkeeth/articles/podcast-premium-feed

台本

オープニング

【ジングル】 はい、どうもこんにちは。「雨宿りと WEB の小噺」、始まりました。Keeth こと桑原です。 今回もちょっとだけ、雨宿りしていきませんか。 今回のお題はですね、「有料ポッドキャスト配信を自前で作ったら沼すぎて、最終的に全部捨てた話」。設計して、実装して、最後に全部捨てるまでの一部始終です。まぁ聞いてってください。

本題

  • そもそも何をやろうとしたか

    • この番組にプレミアムプラン(メンバーシッププラン)を作ろうとしている
    • やりたかったことは4つ
      • Art19で配信している本編に、プレミアム限定エピソードを追加する
      • 自分のポッドキャスト公式サイト(Riot.js + Vite製のSPA)で、通常・プレミアムエピソードを時系列マージして表示する
      • Apple Podcastsなどのポッドキャストアプリでもプレミアムフィードを購読できるようにする
      • 解約したら即座にアクセスを止められるようにする
    • 「外部サービスに頼らず、フルスクラッチで作ってみたい」という個人開発欲もあり、自作することに
    • 現在の配信基盤はArt19。そのAlternate Feed(限定フィード)機能を使ってプレミアムエピソードを配信する前提
  • フェーズ1:シンプルに2つのフィードをマージすればいいんじゃない?

    • 通常フィードとAlt FeedをPromise.allSettled()で両方取得して時系列マージ、で終わるかと思っていた
      • 片方の取得が失敗しても、もう片方だけで表示を継続できる設計
    • 壁1:Art19の埋め込みプレイヤー(iframeベース)が、Alternate Feedのエピソードに対して404を返す
      • 仕方なくHTML5の<audio>タグで直接再生する方式に切り替え
    • 壁2:<audio>タグで再生するにはRSSの<enclosure>タグから音声URLを直接取得する必要がある
      • Viteでビルドすると、Alternate FeedのURLがバンドルに含まれてクライアントJSに丸見え
      • URLを知っていれば誰でもRSSにアクセスして、エピソード一覧も音声URLも取り放題
    • この方式はボツ
  • フェーズ2:サーバーサイドに逃がせばURLを隠せる

    • Firebase Cloud Functionsにフィード取得処理を移し、クライアントからはフィードURLを見せない構成に
    • Cloud Functions側でFirestoreを参照し、「プレミアムユーザかどうか」の認証チェックも実装
    • 壁3:Cloud Functionsから返された音声URL自体が直リンク
      • ユーザがURLをコピーして共有したら、誰でもアクセスできてしまう
    • フィードURLは隠せたが、本質的な問題は解消できず、またボツ
  • フェーズ3:ここで外部サービスを真剣に比較してみた

    • Supercast:月額固定なし、取引額の5.5% + Stripe手数料。ユーザーごとのRSS発行もある。手数料率が惜しい
    • Memberful:月額$25固定 + Stripe手数料。ユーザーごとに固有のハッシュ付きRSSを発行してくれるのが魅力。でも個人運営に月額固定コストは重い
    • Patreon:手数料5〜12%。ただしポッドキャスト特化ではない
    • 他番組の事例も調べた
      • Rebuild.fm:Webポータル経由でダウンロードさせる完全に振り切ったスタイル
      • backspace.fm:Ghost + プレミアムフィードをしっかり運用している
    • 結論(当時):Memberfulの月額$25は厳しい。自前でSupercast相当のものを構築しようと決意
  • フェーズ4:Art19の認証機能を調べてみた → 断念

    • Art19サポートから提案された方式は2つ
    • 方式1:Access Token方式(共有トークンをURLのクエリパラメータに付与)
      • 問題:全ユーザー共通のシークレットなので、1人に漏れたら全員アウト
      • トークンをローテーションすると、登録済みリスナーのフィードURLが全部無効になる
    • 方式2:JWT方式Authorization: Bearer {token}ヘッダーを使う)
      • 致命的な問題:<audio>タグやポッドキャストアプリは、HTTPリクエストに任意のヘッダーを付与できない
    • 結論:どちらの方式でも「ポッドキャストアプリが直接アクセスできる」という要件を満たせない
      • プロキシが必須。そしてプロキシを立てるなら、Art19の認証に依存せず自前で制御した方がシンプル
  • フェーズ5:Cloudflare Workers で認証プロキシを自作

    • Cloudflare Workersを選んだ理由
      • コールドスタートがほぼゼロ(Firebase Functionsは数秒、Lambdaも数百ms〜)
      • 音声ストリーミング(Rangeヘッダーでのシーク)に対応できる
      • KVが内蔵されていて、エッジでの認可チェックが数msで済む
      • 無料枠が1日10万リクエストと十分
    • R2に音声をコピーしなかった理由
      • Art19に新エピソードが追加されるたびに同期する手間、削除・更新時の同期問題、ストレージコスト
      • WorkersがArt19からストリーミングで直送すればプロキシで十分
    • アーキテクチャは2段構成
      • Firebase:ユーザー認証 + Stripeによる課金管理(Firestoreに課金状態を保持)
      • Cloudflare Workers + KV:リクエストごとの認可チェック
    • 署名付きURL + KVの2重チェックで音声へのアクセスを制御
      • 署名が有効でも、KVから削除されていればアクセス不可 → URLが共有されても即時無効化できる
    • StripeのWebhookを起点に、FirestoreとCloudflare KVの両方に書き込むイベント駆動パターン
    • 音声プロキシはRangeヘッダーを透過的に転送し、206(Partial Content)のままポッドキャストアプリに返す
    • 技術的には、これでやりたいことは全部実現できる設計になった
  • フェーズ6:ところが、この設計を全部捨てた

    • 捨てた理由は技術的な課題ではなく、メンテナンス・維持・セキュリティのコスト
    • ランニングコストを試算してみた
      • Cloudflare Workersの無料枠超過:$5〜/月
      • KV操作:$0.5〜/月
      • Firebase Functionsは無料枠内の見込み
      • Stripe手数料:売上の3.6%
    • 金額よりも重かったのが運用負担
      • Cloudflare Workers × Firebase の「クロスクラウド同期」が壊れたときのバグリスク
      • HMAC署名鍵のローテーション、KVのTTL管理
      • Stripe Webhookの冪等性保証と、失敗時のリトライ設計
      • そして個人運営なので、この全部の責任を自分一人で持ち続ける
    • ここで気づいた:「本来やりたいのは配信であって、インフラ運用ではない」
  • 最終形:Substackに一本化

    • Substackの条件
      • 手数料:売上の10%(Stripe手数料込み)
      • 月額固定費:$0
      • 音声ホスティング・認証・会員管理・RSS配信、全部込み
    • 決め手はSubstackのRSSの仕様
      • 無料エピソード:<enclosure>タグ(音声URL)あり
      • 有料エピソード:<enclosure>タグなし
    • つまりサイト側の判定ロジックは「isPremium = !audioUrl」の1行
      • 認証も署名もKVも不要になった
    • 最終アーキテクチャは劇的にシンプル
      • Art19のRSS(公開)とSubstackのRSS(公開)をSPAでマージして時系列表示
      • 無料エピソード → HTML5の<audio>で再生
      • 有料エピソード → Substackへリンクして、認証も再生も全部Substackに委譲
      • Workersに残ったのはCORS対策の45行だけ
    • 捨てたもの:音声プロキシのWorkers、購読管理のKV、Firebase Auth、FirestoreのユーザーDB、Stripe直接統合
    • 受け入れたトレードオフ
      • 手数料3.6% → 10%(売上規模が大きくなったら再検討すればいい)
      • 再生UIのカスタマイズ不可、アクセス解析の完全把握不可、アプリ購読フローの自由度低下
      • これらは「配信が軌道に乗るまでの保険料」として許容
  • 学びと結論

    • 「フィードを2つマージするだけ」のつもりが、セキュリティを真剣に考え始めると芋づる式に問題が出てきた
    • 最大の誤算:<audio>タグはAuthorizationヘッダーを送れない
      • これがわかった瞬間にブラウザ経由での直接認証はすべて不可能になり、プロキシ一択になった
    • Firestore(本体データ)とCloudflare KV(認可キャッシュ)の役割分担は、試行錯誤の末に自然にたどり着いた設計で、これ自体は良い学びだった
    • 設計を捨てる判断について
      • サンクコストの感情はもちろんあった
      • でも「自分が本当にやりたいのは配信であってインフラ運用ではない」という本質に立ち返った
      • フェーズ1〜5の過程は「問題の解像度を上げるための学習投資」として意味があった
    • 最終的な教訓:認証・課金・配信のインフラは、個人運営なら素直に外部サービスに委譲しましょう
      • 自前実装は学びにはなるが、維持し続けるのは別の話

エンディング

【ジングル】 さて、そろそろ今回もお時間です。 面白かったよーという方は、ぜひチャンネル登録もお願いします。話してほしいトピックや感想は、概要欄のフォームか 𝕏 で「WEB 小噺」でつぶやいてください。web はアルファベット、「小噺」は漢字でもひらがなでも大丈夫です! それでは、また雨宿りしに来てください。お相手は Keeth でした。さようなら! 【ジングル】

results matching ""

    No results matching ""