📻

SwiftUI × FFmpegでMac用ラジオ録音アプリを自作した話

に公開

免責事項
本記事は、macOSにおけるURLSessionのヘッダー解析や、外部プロセスの並列実行に関する技術的な研究を目的としています。
作成したコードの商用利用や、著作権法に抵触する利用は推奨しません。

はじめに

個人でMac用のラジオ録音アプリ『Kiko』を開発しました。
当初はApp Storeでの配布も検討しましたが、Radikoのサービス規約や権利関係のクリアが個人開発レベルでは難しいと判断しました。
そこで、アプリそのものを提供するのではなく、開発過程で得た「技術的な知見」を共有することで、同じような課題を持つエンジニアの役に立てればと考えました。

この記事では、Radikoの認証ロジック(通称 auth1/auth2流程)のSwift実装と、Swift Concurrencyを用いた爆速並列ダウンロードの実装について解説します。


1. macOSネイティブなUIへのこだわり

機能だけでなく、UIもmacOSらしさを意識しました。
SwiftUIの NavigationSplitView を採用することで、標準アプリ(メールやメモ)のような親しみやすい3ペイン構成を実現しています。

NavigationSplitView {
    SidebarView(selectedStation: $selectedStation)
} detail: {
    if let station = selectedStation {
        ProgramListView(station: station)
    } else {
        Text("放送局を選択してください")
            .foregroundColor(.secondary)
    }
}

また、カレンダー選択のようなコンポーネントは、標準の DatePicker が小さすぎて使いづらいため、Popover 内で scaleEffect を使ってあえて大きく表示するなど、実用性を重視したカスタマイズを行っています。


ここまでが、アプリの「表側」であるUI実装のこだわりでした。

続いて、アプリの「裏側」にある技術的な核心部分について解説します。
公式ドキュメントのない認証をSwiftだけでどう突破したのか、数千のファイルをどうやって効率的に捌いているのか。
市販のライブラリを使わず、標準機能(URLSession, Swift Concurrency)だけで実装した実践的なコードを紹介します。

2. Radiko認証のハック (KikoAuthManager)

ラジオ録音アプリを作る上で最初の壁となるのが、Radiko独自の認証システムです。
これは公式のドキュメントが存在しないため、通信を解析して実装する必要があります。

認証フローの概要

認証は大きく分けて2段階あります。

  1. Auth1: 特定のアプリ固有キー(authKey)を使ってリクエストを送り、サーバーから「トークン」と「キーの断片情報(offsetとlength)」を受け取る。
  2. Partial Keyの生成: 受け取った情報をもとに、アプリ内のauthKeyから特定のバイト列を切り出し、Base64エンコードする。
  3. Auth2: 生成したPartial KeyとauthTokenをサーバーに送信し、正規の認証トークンを有効化する。

Swiftによる実装 (Auth1 & Partial Key Calculation)

ここが最も重要で、かつトリッキーな部分です。
レスポンスヘッダーに含まれる X-Radiko-KeyLength と X-Radiko-KeyOffset を読み取り、バイナリ操作を行います。

private let authKey = "YOUR_AUTH_KEY" // 実際の実装では適切なキーを設定してください

private func performAuth1() async throws -> (String, String) {
    let url = URL(string: "https://radiko.jp/v2/api/auth1")!
    var request = URLRequest(url: url)
    request.addValue("pc_html5", forHTTPHeaderField: "X-Radiko-App")
    request.addValue("pc_html5_key", forHTTPHeaderField: "X-Radiko-App-Key")
    request.addValue("dummy_user", forHTTPHeaderField: "X-Radiko-User")
    // ... その他 User-Agent 等を設定

    let (_, response) = try await URLSession.shared.data(for: request)
    guard let httpResponse = response as? HTTPURLResponse else {
        throw URLError(.badServerResponse)
    }

    // ヘッダーから必要な情報を取得
    let headers = httpResponse.allHeaderFields
    let token = headers.first { ($0.key as? String)?.lowercased() == "x-radiko-authtoken" }?.value as? String ?? ""
    let lenStr = headers.first { ($0.key as? String)?.lowercased() == "x-radiko-keylength" }?.value as? String ?? "0"
    let offStr = headers.first { ($0.key as? String)?.lowercased() == "x-radiko-keyoffset" }?.value as? String ?? "0"

    guard let length = Int(lenStr), let offset = Int(offStr), !token.isEmpty else {
        throw NSError(domain: "RadikoAuth", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to get auth headers"])
    }

    // Partial Key の生成ロジック
    // 実際の実装ではバイナリデータとして扱う方が安全ですが、ここでは簡易的に文字列操作で説明します
    let longKey = authKey + authKey // キーを連結したものをソースとする場合もあるようですが、実装依存です
    // SwiftのString.Indexを使って切り出す例
    let start = longKey.index(longKey.startIndex, offsetBy: offset)
    let end = longKey.index(start, offsetBy: length)
    let partialKey = Data(String(longKey[start..<end]).utf8).base64EncodedString()

    return (token, partialKey)
}

この partialKey の計算が1バイトでもズレると認証は通りません。
Swiftの String はUnicode対応でインデックス操作が少し特殊なので、バイト単位で確実に処理したい場合は Data 型で扱うのが確実です。


3. Swift Concurrencyによる並列ダウンロード (FFmpegRunner)

次に、番組のダウンロード機能です。
Radiko(タイムフリー)はHLS (HTTP Live Streaming) 形式で配信されており、数秒ごとの小さな .aac ファイル(セグメント)に分割されています。
1時間の番組だと数百〜数千ファイルになり、これらを直列にダウンロードしていると非常に時間がかかります。

そこで Swift Concurrency の TaskGroup を利用して、並列ダウンロードを実装しました。

TaskGroupによる並列制御

単に Task を大量生成するとサーバーに過負荷をかけたり、システムのリソースを食いつぶしたりします。
そこで、同時に実行されるタスク数を制御しながらダウンロードを行うパターンを実装しました。

private func downloadSegmentsParallel(
    urls: [String], 
    to directory: URL, 
    headers: [String: String]
) async throws -> [URL] {
    
    return try await withThrowingTaskGroup(of: (Int, URL).self) { group in
        var results: [Int: URL] = [:]
        var urlIterator = urls.enumerated().makeIterator()
        var activeCount = 0
        let maxParallel = 10 // 同時接続数を10に制限

        // 1. 最初のバッチを投入
        while activeCount < maxParallel, let (index, urlStr) = urlIterator.next() {
            activeCount += 1
            group.addTask {
                return try await self.downloadSegment(index: index, urlStr: urlStr, to: directory, headers: headers)
            }
        }

        // 2. 完了したタスクから順次補充
        while let result = try await group.next() {
            results[result.0] = result.1
            activeCount -= 1

            // 次のタスクがあれば追加
            if let (index, urlStr) = urlIterator.next() {
                activeCount += 1
                group.addTask {
                    return try await self.downloadSegment(index: index, urlStr: urlStr, to: directory, headers: headers)
                }
            }
        }

        // インデックス順にソートしてパスを返す
        let sortedFiles = results.sorted { $0.key < $1.key }.map { $0.value }
        return sortedFiles
    }
}

この実装により、常に一定の並列数をキープしながら効率的にダウンロードが進みます。
ダウンロード完了後は、FFmpegを使ってこれらの断片を一本のファイルに結合します。

// FFmpegへの結合コマンド例
// -bsf:a aac_adtstoasc はAACのストリーム形式を正すために必須です
let arguments = [
    "-f", "concat",
    "-safe", "0",
    "-i", listFileListPath,
    "-c", "copy",
    "-bsf:a", "aac_adtstoasc", 
    "-y",
    outputPath
]

おわりに

Macアプリ開発は、iOSとはまた違った楽しさと難しさがあります。
特に今回は以下の3点が大きな学びとなりました。

  • 非公開APIの解析と実装: HTTPヘッダーやバイナリ操作の理解
  • 高効率な並列処理: Swift Concurrency (TaskGroup) の実践的な活用
  • macOSらしいUI構築: NavigationSplitViewやWindow管理のノウハウ

「動くものを作る」というプロセス自体が非常に学びになりましたし、こうして技術をシェアすることで誰かの役に立てれば嬉しいです。

Discussion