自分の iPhone から自分で転送した楽曲を取り戻す
どうも、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 では、そのままインストールできそうでした。
次に ifuse をインストールします。
実際のインストールは osxfuse
と同様に Homebrew を用いて以下のように行います。
brew install ifuse
これで iPhone をファイルシステムとしてマウントする準備ができました。
マウントしたい iPhone のみ を Mac に接続し、以下のコマンドを実行します。
mkdir ~/iPhone
ifuse ~/iPhone
うまく行くと Finder でホームを表示した際に以下のように OSXFUSE Volume 1 (ifuse)
と表示されます。
中を見るといろいろとディレクトリがありますが iTunes_Control/Music
の中身に楽曲が入っているようです。
本来ならこれで解決なのですが、ディレクトリ内部の分類とファイル名が人類には早すぎました 😇
iTunes_Control
ディレクトリを解析する
実は ifuse に出会う前にいくつか iPhone のデータ復旧用ソフトウェアのトライアルを試していました。
そのソフトウェアは楽曲の抽出は人に優しい名前付で出力していたので、必ず元のディレクトリ構造に戻す方法があるはずです。
ということで iTunes_Control
ディレクトリを眺めていると MediaLibrary.sqlitedb
という「俺を開いて読んでくれ!」と言わんばかりのファイルがあったので解析しました。
内容を眺めていると、特に暗号化とかもされておらず素直な感じの DB 構造になっていました。
ちょっと変わっていたのはファイルの完全なパスは base_location
というテーブルの情報と item_extra
というテーブルの情報を合わせる必要があった点くらいです。
Rust で SQLite を読んでいい感じにファイルをコピーする
適当に SQL 叩いていたら必要な情報は全部取れそうなことがわかったので、勉強がてら Rust で該当ファイルを読んでいい感じにコピーすることにしました。
Rust で DB といえば Diesel でしょうが、今回はちょっとしたツールを作るだけだったので、適当にググって良さそうだった rusqlite と serde_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)
}
本当は 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>
な値をうまく取り出すのに苦労しました。この辺り、より簡素な書き方をご存知の方はご教授いただけると幸いです。
あとは src
と dst
を使って「ディレクトリの自動生成」と「ファイルのコピー」を以下のように行います。
if !&src.exists() {
// ファイルがない場合もあったので、その時はスキップ
continue;
}
// 出力に必要な親ディレクトリを全部作成
fs::create_dir_all(&dst.parent().unwrap())?;
// ファイルコピー
fs::copy(&src, &dst)?;
これを実行すると、いい感じにディレクトリわけされた楽曲ファイルが手に入ります。
ただし、当たり前ですが元からファイル自体にタグ情報がついていなかったファイルにはタグ情報はついていません。
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>
としたため、少し扱いに少し苦労しました。
上記のようなことを試行錯誤しながら簡単なツール化したものが
となります。もう二度と楽曲を失うつもりはありませんが、一応汎化しました 😇
まとめ
HDD のフォーマットは慎重に
Discussion