自分の iPhone から自分で転送した楽曲を取り戻す

13 min read読了の目安(約11900字

どうも、NAS の移行に伴う全データ移行前に誤って HDD をフォーマットしてしまい、中高時代に取り溜めていた楽曲が吹っ飛んだ、ありすえです。
今回は失ってしまった楽曲を復活させるために、必死に iPhone から楽曲データを取り出した記録です。

tl;dr

ifuse を利用して iPhone をマウントして Finder でアクセスしてデータを引っこ抜く。
その後 rebuild-from-itunes-control を利用して適切なタグ付と配置されたファイルを生成する。

序章

新しい OS がリリースされるたびに OS のクリーンインストールをする方は一定数以上いると思います。ただ、人間は忘れやすい生き物なので、そういう運用をしていると大体 iPhone との同期設定が外れてしまいます。
同期設定が外れてしまうと iPhone のデータにアクセスしようと「〜を "xxxxx" と同期」にチェックを入れても「全データ消えるけど大丈夫?」と聞かれて先に進めません 😇

普段なら完全放置するのですが、今回は iPhone のみがデータを保持していたので仕方なく復旧方法を調査しました。
その結果、どうも Jailbreak していない状態の iPhone でも、一部のソフトウェアを使うことでデータの復旧ができそうだということが判明しました。
さらに調査を継続すると、幸いにも How to access iPhone files with a disk mount という記事を見つけることができました。

iPhone をファイルシステムにマウントする

この章は How to access iPhone files with a disk mount の要約です。
より詳しい情報を知りたい方はオリジナルに当たってください。

まず FUSE を利用するため macFUSE の古いバージョンである osxfuse をインストールします。
実際のインストールは Homebrew を用いて以下のように行います。

brew install osxfuse

ちなみに、オリジナル記事では tap して osxfuse をインストールしていますが 2021/01 現在の Homebrew では、そのままインストールできそうでした。

osxfuse は FUSE for OS X -> FUSE for macOS -> macFUSE と名前が遷移しており、Homebrew を利用してインストールするべきなのは macfuse ですが osxfuse を入れないと ifuse をインストールする際に以下のように怒られてしまったため、仕方なく osxfuse を利用しています。

ifuse: FUSE for macOS is required for this software. OsxfuseRequirement unsatisfied!
You can install the necessary cask with:
  brew install --cask osxfuse
You can download from:
  https://osxfuse.github.io/
Error: An unsatisfied requirement failed this build.

余談ですが、MacFUSE の頃を知っていると macFUSE という名前は余計ややこしいですね 😅

次に ifuse をインストールします。
実際のインストールは osxfuse と同様に Homebrew を用いて以下のように行います。

brew install ifuse

これで iPhone をファイルシステムとしてマウントする準備ができました。
マウントしたい iPhone のみ を Mac に接続し、以下のコマンドを実行します。

mkdir ~/iPhone
ifuse ~/iPhone

うまく行くと Finder でホームを表示した際に以下のように OSXFUSE Volume 1 (ifuse) と表示されます。

中を見るといろいろとディレクトリがありますが iTunes_Control/Music の中身に楽曲が入っているようです。

本来ならこれで解決なのですが、ディレクトリ内部の分類とファイル名が人類には早すぎました 😇

ちなみに mp3 ファイルはタグが残っていたためタグからファイル名の自動生成はできそうでした。
ただ m4a ファイルはタグすら書き込まれていなかったので該当ディレクトリを対象として Music アプリで読み込んでも適切なファイル名にはなりませんでした。

iTunes_Control ディレクトリを解析する

実は ifuse に出会う前にいくつか iPhone のデータ復旧用ソフトウェアのトライアルを試していました。
そのソフトウェアは楽曲の抽出は人に優しい名前付で出力していたので、必ず元のディレクトリ構造に戻す方法があるはずです。

ということで iTunes_Control ディレクトリを眺めていると MediaLibrary.sqlitedb という「俺を開いて読んでくれ!」と言わんばかりのファイルがあったので解析しました。

内容を眺めていると、特に暗号化とかもされておらず素直な感じの DB 構造になっていました。
ちょっと変わっていたのはファイルの完全なパスは base_location というテーブルの情報と item_extra というテーブルの情報を合わせる必要があった点くらいです。

Rust で SQLite を読んでいい感じにファイルをコピーする

適当に SQL 叩いていたら必要な情報は全部取れそうなことがわかったので、勉強がてら Rust で該当ファイルを読んでいい感じにコピーすることにしました。

上記のように Rust は自信がある言語ではありません。記事内のコードの変な書き方や、より良い方法等があればご教授いただけると助かります 🙏

Rust で DB といえば Diesel でしょうが、今回はちょっとしたツールを作るだけだったので、適当にググって良さそうだった rusqliteserde_rusqlite の組み合わせで行くことにしました。

とりあえず rusqlite のチュートリアルと事前に試行錯誤した SQL の情報から、ざっくり以下のようなコードを書いて情報取得できるか試してみます。

use anyhow::Result;
use rusqlite::{Connection, NO_PARAMS};
use serde_derive::{Deserialize, Serialize};
use serde_rusqlite::*;
use std::path::Path;

#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct Entry {
    // item_extra
    pub title: String,
    pub location: String,
    // base_location
    pub path: String,
    // item_artist
    pub item_artist: Option<String>,
    // album
    pub album: Option<String>,
    // album_artist
    pub album_artist: Option<String>,
}

fn main() -> Result<()> {
    let home = dirs::home_dir().unwrap();
    let root = home.join("iPhone");
    let path = root.join("iTunes_Control/iTunes/MediaLibrary.sqlitedb");
    let entries = read_entries(&path)?;

    println!("{:?}", entries);

    Ok(())
}

fn read_entries(path: &Path) -> Result<Vec<Entry>> {
    let conn = Connection::open(path)?;

    let mut stmt = conn.prepare(
        r#"SELECT * FROM item
            INNER JOIN item_extra USING(item_pid)
            INNER JOIN base_location USING(base_location_id)
            LEFT JOIN item_artist USING(item_artist_pid)
            LEFT JOIN album USING(album_pid)
            LEFT JOIN album_artist USING(album_artist_pid)
            WHERE location != "" AND path != ""
        "#,
    )?;
    let items: Vec<_> = from_rows::<Entry>(stmt.query(NO_PARAMS)?)
        .flatten()
        .collect();
    Ok(items)
}

なお以下のライブラリを利用しています

  • dirs
  • anyhow
  • rusqlite
  • serde_derive
  • serde_rusqlite

本当は SELECT * ではなく、ちゃんと指定するべきでしょうが、試行錯誤しているので面倒です。
こういう時 serde_rusqlite を使っていると、定義 (Entry) とカラムを勝手に紐づけた上で定義に存在しないカラムは無視してくれるため、とても書き心地が良かったです。

上記を cargo run で実行すると、ファイルパスの構築に必要な情報がちゃんと取れていそうでした。
なので上記データを元にファイルのコピー元とコピー先を構築するため、以下のような関数を足しました。

fn build_src(entry: &Entry, root: &Path) -> PathBuf {
    root.join(&entry.path).join(&entry.location)
}

fn build_dst(entry: &Entry, root: &Path) -> PathBuf {
    // XXX
    // この辺必要以上に複雑な書き方になっている気がするが
    // 他に良い書き方が思い浮かばなかった
    let art = entry
        .item_artist
        .as_ref()
        .map(String::to_owned)
        .unwrap_or("Unknown artist".to_string());
    let art = entry
        .album_artist
        .as_ref()
        .map(String::to_owned)
        .unwrap_or(art);
    let alb = entry
        .album
        .as_ref()
        .map(String::to_owned)
        .unwrap_or("Unknown album".to_string());
    let ext = Path::new(&entry.location)
        .extension()
        .and_then(OsStr::to_str)
        .unwrap_or("");
    root.join(art)
        .join(alb)
        .join(format!("{}.{}", &entry.title, ext))
}

やっていることは entry から src/dst の構築なので、それほど難しくないはずなのですが &Entry 内の Option<String> な値をうまく取り出すのに苦労しました。この辺り、より簡素な書き方をご存知の方はご教授いただけると幸いです。

vim-jp の Slack で monaqa さんに Rust の型変換イディオム
で紹介されている as_deref(): Option<String> -> Option<&str> を利用すると、上記の XXX 部分を簡潔にかけると教わりました。

fn build_dst(entry: &Entry, root: &Path) -> PathBuf {
    let art = entry.item_artist.as_deref().unwrap_or("Unknown artist");
    let art = entry.album_artist.as_deref().unwrap_or(art);
    let alb = entry.album.as_deref().unwrap_or("Unknown album");
    let ext = Path::new(&entry.location)
        .extension()
        .and_then(OsStr::to_str)
        .unwrap_or("");
    root.join(art)
        .join(alb)
        .join(format!("{}.{}", &entry.title, ext))
}

unwrap_or() に対しても &str で良くなるため、とても良いですね!ありがとうございました。

あとは srcdst を使って「ディレクトリの自動生成」と「ファイルのコピー」を以下のように行います。

if !&src.exists() {
    // ファイルがない場合もあったので、その時はスキップ
    continue;
}
// 出力に必要な親ディレクトリを全部作成
fs::create_dir_all(&dst.parent().unwrap())?;
// ファイルコピー
fs::copy(&src, &dst)?;

これを実行すると、いい感じにディレクトリわけされた楽曲ファイルが手に入ります。
ただし、当たり前ですが元からファイル自体にタグ情報がついていなかったファイルにはタグ情報はついていません。

ifuse は無茶苦茶読み取りが遅いので、実際のファイルコピーを試行錯誤する前にローカルにコピーしておき、そちらを利用して試行錯誤した方が良いです。

Rust で SQLite を読んでいい感じに MP3 や MPEG4 のタグをつける

ここまできたので MediaLibrary.sqlitedb に保存されているメタ情報をファイルにタグ情報として書き出すところまでやってみました。

幸いなことに MP4 および MPEG4 のタグ情報は以下のインターフェースが似たライブラリを使うことで簡単に書き込むことができました。

対象 ライブラリ
MP3 ファイル (ID3 metadata) rust-id3
M4A ファイル (iTunes style MPEG-4 audio metadata) rust-mp4ameta

MP3/M4A どちらでも透過的にタグを書き込めるように metadata というモジュールを以下のような内容で追加しました。

use anyhow::{anyhow, Context, Result};
use std::ffi::OsStr;
use std::path::Path;

#[derive(Debug, PartialEq)]
pub struct Metadata {
    title: Option<String>,
    artist: Option<String>,
    album: Option<String>,
    album_artist: Option<String>,
    genre: Option<String>,
    disc: Option<u16>,
    total_discs: Option<u16>,
    track: Option<u16>,
    total_tracks: Option<u16>,
}

impl Metadata {
    pub fn new(
        title: Option<impl Into<String>>,
        artist: Option<impl Into<String>>,
        album: Option<impl Into<String>>,
        album_artist: Option<impl Into<String>>,
        genre: Option<impl Into<String>>,
        disc: Option<u16>,
        total_discs: Option<u16>,
        track: Option<u16>,
        total_tracks: Option<u16>,
    ) -> Self {
        Metadata {
            title: title.map(|v| v.into()),
            artist: artist.map(|v| v.into()),
            album: album.map(|v| v.into()),
            album_artist: album_artist.map(|v| v.into()),
            genre: genre.map(|v| v.into()),
            disc,
            total_discs,
            track,
            total_tracks,
        }
    }
}

pub fn write_metadata<P: AsRef<Path>>(path: P, meta: &Metadata) -> Result<()> {
    let path = path.as_ref();
    let ext = path.extension().and_then(OsStr::to_str);
    match ext {
        Some("mp3") => write_metadata_on_mp3(&path, &meta),
        Some("mp4") => write_metadata_on_m4a(&path, &meta),
        Some("m4a") => write_metadata_on_m4a(&path, &meta),
        Some(ext) => Err(anyhow!("Unknown file extension '{}' has specified", &ext,)),
        _ => Err(anyhow!(
            "The path '{}' does not have extension",
            &path.to_str().unwrap_or("failed to unwrap 'path'")
        )),
    }
}

fn write_metadata_on_mp3(path: &Path, meta: &Metadata) -> Result<()> {
    let mut tag = id3::Tag::read_from_path(path)?;
    meta.title.as_ref().map(|v| tag.set_title(v.to_owned()));
    meta.artist.as_ref().map(|v| tag.set_artist(v.to_owned()));
    meta.album.as_ref().map(|v| tag.set_album(v.to_owned()));
    meta.album_artist
        .as_ref()
        .map(|v| tag.set_album_artist(v.to_owned()));
    meta.genre.as_ref().map(|v| tag.set_genre(v.to_owned()));
    meta.disc.map(|v| tag.set_disc(v as u32));
    meta.total_discs.map(|v| tag.set_total_discs(v as u32));
    meta.track.map(|v| tag.set_track(v as u32));
    meta.total_tracks.map(|v| tag.set_total_tracks(v as u32));
    tag.write_to_path(path, id3::Version::Id3v24)
        .with_context(|| format!("Failed to write ID3v2.4 metadata"))
}

fn write_metadata_on_m4a(path: &Path, meta: &Metadata) -> Result<()> {
    let mut tag = mp4ameta::Tag::read_from_path(path)?;
    meta.title.as_ref().map(|v| tag.set_title(v.to_owned()));
    meta.artist.as_ref().map(|v| tag.set_artist(v.to_owned()));
    meta.album.as_ref().map(|v| tag.set_album(v.to_owned()));
    meta.album_artist
        .as_ref()
        .map(|v| tag.set_album_artist(v.to_owned()));
    meta.genre.as_ref().map(|v| tag.set_genre(v.to_owned()));
    meta.disc
        .zip(meta.total_discs)
        .map(|v| tag.set_disc(v.0, v.1));
    meta.track
        .zip(meta.total_tracks)
        .map(|v| tag.set_track(v.0, v.1));
    tag.write_to_path(path)
        .with_context(|| format!("Failed to write iTunes style MPEG-4 audio metadata"))
}

二つのライブラリのインターフェースがかなり似ていたため、非常に簡単でした。
ただ、未指定項目は上書きしないようにするために Option<String> としたため、少し扱いに少し苦労しました。

上記のようなことを試行錯誤しながら簡単なツール化したものが

https://github.com/lambdalisue/rs-rebuild-from-itunes-control

となります。もう二度と楽曲を失うつもりはありませんが、一応汎化しました 😇

まとめ

HDD のフォーマットは慎重に