Music Library Playback and Import - log5r/simple-media-player GitHub Wiki

iPhone/iPad での Music ライブラリの一時再生と取り込み

Issue #22 を実装する PR #54 の記録。2026-10-10、main の 523459e を基点に調査した。iPhone/iPad の Music ライブラリにある端末上の DRM なしの曲を、ライブラリへ保存せずに再生する操作と、アプリのライブラリへ取り込む操作を追加した。

調査で確認した外部契約

MPMediaItem.assetURL は ipod-library:// スキームの URL で、通常のファイル URL ではない。FileManager.copyItem や既存の importFiles(from:) に渡しても使えないため、AVURLAsset を通して書き出す。パス部分の拡張子(item.m4a など)は PCM 曲の出力コンテナ(AIFF/WAV)を選ぶ手がかりにだけ使う。

AVAssetExportSession の supportedFileTypes は、プリセットが書けるコンテナを資産の符号化に関係なく列挙する。macOS 27 で生成した AAC/ALAC の M4A に対して WAV/AIFF も列挙されたが、実際の書き出しは -11838(この媒体では未対応)で失敗した。compatibleFileTypes(非同期)は符号化を反映し、AAC/ALAC では M4A・CAF・MOV 系だけを返した。実装は compatibleFileTypes だけを使う。

パススルー書き出しは、PCM(WAV/AIFF)を WAV・AIFF・CAF・M4A へ、AAC と ALAC を M4A・CAF へ符号化を変えずに書き出せた。AVAssetReader に出力設定なしでトラックを読むと符号化済みのパケットが得られ、MP3 では各パケットが MPEG フレームなので、順に連結したファイルは ID3 なしの MP3 として AVAudioFile で読める。MP3 のパススルーを M4A に入れることはできないため、この経路を MP3 専用に使う。

MPMediaPickerController の showsCloudItems = false と showsItemsWithProtectedAssets = false は表示の絞り込みであり、選択結果の保証ではない。選択した各項目について hasProtectedAsset、isCloudItem、assetURL を再確認し、さらに AVURLAsset の isReadable、hasProtectedContent、isExportable と音声トラックの有無を書き出し前に確認する。MPMediaPickerControllerDelegate は MainActor 注釈がない ObjC プロトコルのため、コーディネーターは @MainActor クラスで @preconcurrency 準拠にした。

設計判断

保存せず再生

MPMusicPlayerController.applicationQueuePlayer は EQ、キー・速度、ビジュアライザー、音楽解析のいずれも通らないため採用しなかった。選んだ曲を一時ディレクトリ(tmp/MusicLibrary/Playback/<UUID>/)へ書き出し、現行の AudioEngineService で再生する。これで既存機能がすべて使える。

一時ファイルの寿命は TransientPlaybackSession が持つ。PlayerViewModel はセッション内の項目が現在の曲またはキューにある間だけセッションを保持し、ライブラリの曲へ切り替えたとき、clearCurrentItem() のとき、新しいセッションへ切り替えた後に前のセッションを終了してディレクトリを削除する。stop() は現在の曲を保持するため削除しない。起動時には tmp/MusicLibrary 全体を削除する。

再生用の項目は ModelContext に挿入しない MediaItem で、ブックマークは一時ファイルを指す。SwiftData の恒久ストアとライブラリ件数は増えない。MediaItem.isInLibrary(modelContext != nil && !isDeleted)を UI の判定に使い、デッキの「情報を編集」「歌詞を編集」「調整済みコピーを保存」「AAC 版を作成」、歌詞パネルの編集ボタン、iPad のピッチ・速度パネルの保存、メニューコマンドを無効化する。歌詞・情報の表示、EQ、速度・ピッチ、可視化、解析は通常どおり動く。

準備中の切り替えは MusicLibraryPreparation の世代管理と PlayerViewModel.playbackGeneration の照合で扱う。新しい準備を始めると前の準備は .replaced として取り消され、完了済みでも結果を適用せずファイルを削除する。ユーザーのキャンセルは .cancelled として区別し、取り込みではキャンセルを報告する。書き出し完了時に再生世代が要求時と異なる(別の曲の再生、停止、クリアがあった)場合は再生を始めず、セッションを終了する。

アプリのライブラリへ取り込み

複数選択を許可し、各曲を取り込み用の一時ディレクトリへ書き出した後、既存の importFiles(from:overrides:) に渡す。検証(AVAudioFile のデコード、トラック確認)、SHA-256 のハッシュ重複判定、メディアディレクトリへのコピー、登録は既存経路を共用する。書き出しは毎回バイト列が変わるためハッシュでは同じ曲を認識できない。MediaItem.musicLibraryItemID に persistentID を保存し、ファイルが現存する同じ ID の項目があれば書き出し前に飛ばして「すでにライブラリにあります」と報告する。

書き出しは符号化ごとに決める。MP3 はフレーム連結、AAC/ALAC は M4A パススルー、PCM は元の拡張子に応じて AIFF または WAV(互換なら)、それ以外は AVAssetExportPresetAppleM4A で AAC へ変換し「AAC に変換しました」と報告する。音質上、変換は非対応の符号化に限り、対応形式はバイト単位の同一性を保証しないが符号化を維持する。

メタデータの優先順位は「Music の値 > ファイルの埋め込み値」とし、取り込み後は既存の編集値の優先順位に従う。Music の文字項目は一式を置き換え、Music で空の項目は空のままにする(タイトルだけは常に値を持つ)。アートワークと歌詞は Music に値がある場合だけ置き換える。書き出したファイルには、取り込み前にアプリのライター(ID3/MP4/AIFF/WAV)で同じ値を埋め込み、ファイルと項目の値をそろえる。埋め込めない形式や失敗時は項目の値だけを上書きし、hasEditedTextMetadata を立てて編集画面がファイルの古いタグを読まないようにする。削除は従来どおりアプリ内のコピーだけが対象で、Music の原本は変更しない。

権限

iOS 向けに NSAppleMusicUsageDescription を [sdk=iphone*] で追加し、macOS の NSAppleEventsUsageDescription と分けた。説明文は InfoPlist.xcstrings に日英で持つ。拒否時は「設定を開く」付きのアラート、制限時は説明のアラートを出し、未決定なら MPMediaLibrary.requestAuthorization を呼ぶ。

検証

環境は macOS 27、Xcode 27.1 Beta、Swift 6。

条件 確認方法 結果
符号化ごとの書き出し 生成した WAV/AIFF/AAC/ALAC と FFmpeg 製の MP3 フィクスチャを MusicLibraryTrackExporter で書き出し、出力形式・フレーム数・AVAudioFile の読み込みを確認 WAV→WAV、AIFF→AIFF、AAC/ALAC→M4A、MP3→MP3(同期語で始まる)。変換フラグは偽
書き出したコピーへのタグ埋め込み MP3/M4A/WAV の出力に Music の値を書き、AVURLAsset のタイトルを読む 三形式とも埋め込み成功、再生可能
書き出しのキャンセル・失敗 開始前のキャンセルと存在しない入力 CancellationError、出力ファイルなし
取り込み ローカルファイルを Music 項目に見立てた MusicLibraryTrack で取り込み Music の値と musicLibraryItemID を持つ項目が 1 件、一時ファイルは削除
同じ曲の再取り込み 同じ ID をもう一度取り込み 書き出しせず飛ばし、件数とファイル数は不変
保護・クラウド・URL なし・読めない項目 四条件を一度に取り込み 各理由を報告し、項目もファイルも増えない
取り込みのキャンセル 4 曲の準備中にキャンセル 登録なし、一時ファイルなし、キャンセルを報告
一時再生の準備 1 曲を準備 未挿入の MediaItem、ライブラリ件数 0、セッション終了でディレクトリ削除
準備の置き換え・キャンセル 準備中に別の曲を準備/キャンセル 古い準備は .cancelled、新しい準備だけ成功、一時ファイルなし
取り込み上書きの優先順位 埋め込みタグ付き M4A と無タグ WAV に上書きを適用 上書き値が優先、埋め込まれなかった場合だけ編集済みフラグ
プレーヤーのセッション寿命 偽のエンジンで再生・停止・次の曲・ライブラリ曲への切り替え・クリア・セッション置換・解決失敗 停止と曲送りは保持、切り替え・クリア・置換・失敗で終了しディレクトリ削除
文言 日英の文字列カタログと InfoPlist 追加キーと権限説明の翻訳を確認

macOS の単体テストは全 632 件(新規 22 件)が成功した。iOS Simulator 向けビルドが成功し、iPhone 18 Pro と iPad Pro 11-inch(iOS 27.0)のシミュレータで、メニューの入口とアラートを確認する UI テストが成功した。権限を xcrun simctl privacy <device> revoke media-library com.log5.SimpleMediaPlayer で拒否した状態では「設定を開く」付きのアラート、reset で未決定の状態では UI テストの割り込みモニターがシステムの確認を拒否し、同じアラートに至る。SwiftLint は既存警告 46 件(main は 47 件)で、ベースラインは移動した取り込み関数の既存 2 件の記録を更新しただけで新しい違反は追加していない。

シミュレータの制約

シミュレータで MPMediaPickerController を提示すると「Internal error: The requested app extension could not be found」のシートになる。ピッカーはアプリ拡張として提供され、シミュレータでは利用できない。アプリは targetEnvironment(simulator) で権限確認後にその旨を表示し、ピッカーを開かない。UI テストではシステムの権限確認の文言がデバイス言語に従うため、ボタンを名前でなく先頭位置で選ぶ。waitForExistence では割り込みモニターが動かないので、アラートが出ない場合に app.tap() で促す。xcodebuild には -collect-test-diagnostics never を付けないと、失敗時の診断収集が 600 秒待つことがあった。

未検証の条件

シミュレータには Music ライブラリがないため、実機でしか確認できない項目が残る。実機の iPhone/iPad で、購入済み AAC、同期した MP3/ALAC/AIFF/WAV、DRM 付き、未ダウンロードの各曲について、ピッカーの表示、一時再生の再生/一時停止/停止/再開/シーク/終端、取り込み後の再起動・情報表示・編集・書き出し・削除、応答性を確認する必要がある。MPMediaItem.releaseDate から年を取る変換、アートワークのサイズ、AVAssetExportSession.export(to:as:) のタスクキャンセルの実挙動、大きなライブラリでの取り込み時間も未計測である。assetURL の拡張子は AIFF/WAV の出力選択にだけ使うため、拡張子が得られない場合は WAV になる。

レビューで変更した点

PR #54 の Codex レビューは 18 回の往復で、主に非同期処理の競合とキャンセルに関する指摘だった。妥当と判断して反映した主な変更は次のとおり。

  • 取り込みの文字項目は Music の値を一式として適用し(MediaImportOverride.replacesTextFields)、ファイルへ埋め込めない場合も Music で空の項目は空にする。アートワークと歌詞は Music に値がある場合だけ置き換える方針を維持した。埋め込めなかったアートワークは MediaItem.hasEditedArtwork で編集画面の復元を防ぐ。
  • importFiles は要求ごとの MediaImportSummary を返し、保存失敗時は挿入した項目とコピーを取り消して件数に数えない。直列化した取り込み内でも Music の曲 ID を再確認し、事前確認は選択した ID に限定してキャンセル可能にした。
  • 遅い完了の判定は PlayerViewModel.transportGeneration(再生・一時停止・再開・停止・クリアで進む)と DeferredPlaybackRequest(最新の選択だけが適用でき、別トラック選択・クリアで無効)で行う。
  • 一時再生セッションの終了は解析タスクの完了後に行い、音楽解析キャッシュの項目も削除する。起動時の掃除はルートを改名してから背景で削除し、異常終了したセッションのキャッシュ項目も取り除く。
  • 書き出しのキャンセルは AVAssetReader.cancelReading() と AVAssetExportSession.cancelExport() を直接呼び、デコード検証とファイル確認は FileSystemWorkQueue.runCancellable と共有リミッターで待ちを放棄する。
  • アートワークの描画はピッカーの応答では行わず、準備中に曲ごとに取得して JPEG 変換は分離タスクで行う。

最終的に macOS の単体テスト 652 件が CI で成功し、SwiftLint は既存警告 46 件のままだった。手元では VideoPlaybackClockTests の 1 件がシミュレータ利用後の環境要因で失敗したが、変更前の状態でも同様に失敗し、CI では成功している。

⚠️ **GitHub.com Fallback** ⚠️