しばやん雑記

Azure とメイドさんが大好きなフリーランスのプログラマーのブログ

Windows Property System を使って C# から曲情報を取得する

GW なので趣味アプリの開発を行っていると、音楽ファイルに保存された曲情報を取得する必要が出てきたので、Windows Property System を使って実現したという話です。

曲情報というのは以下のようにファイルのプロパティから確認出来るメタデータのことです。

単純にファイルからメタデータを読み込むだけなら TagLib# というライブラリを使うと、特に難しい処理が必要なく読み取ることが出来ます。しかし最近はアップデートがほぼ止まっていて、偶に PR はマージされていますがリリースがされていない状態が続いています。

そういう経緯もあり、別の方法を検討した結果 Windows Property System に辿り着きました。

Windows Explorer やファイルのプロパティから確認出来る各種メタデータは、Windows Property System という統一されたインターフェースで任意のアプリケーションから利用できるようになっています。

特定のファイル形式に依存しないインターフェースになっていて、Windows で対応しているファイルなら扱えるというのがポイントです。当然ながら COM で実装されているので、TagLib# 程の手軽はありません。

とはいえ必要なインターフェースは少なく、最近では CsWin32 が使えるので楽勝かと思ったのですが、必要となる PROPVARIANT 構造体が複雑すぎて少しはまりました。

メモリの利用効率を高めるために巨大な共用体として実装されているのですが、これが C# から利用する上で問題となりました。この構造体を何とかするのが Windows Property System を使う上でのメイン課題です。

CsWin32 を使ってマーシャリング無しで頑張る

令和にもなって P/Invoke 定義を全て書きたくはないので、今回も CsWin32 を使って定義を自動生成させてみます。以前に CsWin32 を使う方法は書いたので、詳細は以下のエントリを参照してください。

必要となる関数・インターフェースは SHGetPropertyStoreFromParsingNameIPropertyStore ぐらいなので、この 2 つを CsWin32 を使って自動生成させます。

生成された P/Invoke 定義を使って、曲名を取得するコードとして以下のような例を用意しました。

var filePath = @"...";

PInvoke.SHGetPropertyStoreFromParsingName(filePath, null, GETPROPERTYSTOREFLAGS.GPS_DEFAULT, typeof(IPropertyStore).GUID, out var ppv);

var propertyStore = (IPropertyStore)Marshal.GetUniqueObjectForIUnknown(new IntPtr(ppv));

propertyStore.GetValue(PInvoke.PKEY_Title, out var pvTitle);
var title = new string(pvTitle.Anonymous.Anonymous.Anonymous.pwszVal);
PInvoke.PropVariantClear(ref pvTitle);

Marshal.ReleaseComObject(propertyStore);

パッと見は問題なさそうなコードですが、実行時に TypeLoadException が発生して上手く動きません。

型名がアレなので何のことかわからなくなりそうですが、CsWin32 で自動生成された PROPVARIANT 型の読み込みに失敗しています。具体的にはマーシャリングがデフォルトで有効になっているので、値型と参照型が同じオフセットに配置されてしまうことでエラーになっています。

構造体のレイアウトの制約に関しては、以下のにぃにの記事を参照してください。公式ドキュメントでの言及は見つからなかったのですが、言われてみるとそうだなという感じです。

この PROPVARIANT 型は結構いろいろな場所で出てくるらしく、CsWin32 のリポジトリにも既にいくつか Issue が作成されていました。結論としてはマーシャリングを有効にしていると回避は不可能のようです。

仕方ないので Issue にあるようにマーシャリングを無効にし、同様のコードを書いて確認します。

CsWin32 でマーシャリングを無効にするとポインターが必須になるので unsafe な文脈でコードを書く必要があります。以下の例が動作を確認出来たコードとなります。

var filePath = @"...";

// 指定したファイルの IPropertyStore を作成
PInvoke.SHGetPropertyStoreFromParsingName(filePath, null, GETPROPERTYSTOREFLAGS.GPS_DEFAULT, typeof(IPropertyStore).GUID, out var ppv);

// 返ってきたポインタの型は void* なので IPropertyStore* にキャストする
var propertyStore = (IPropertyStore*)ppv;

// 曲名を取得
propertyStore->GetValue(PInvoke.PKEY_Title, out var pvTitle);
var title = new string(pvTitle.Anonymous.Anonymous.Anonymous.pwszVal);
PInvoke.PropVariantClear(ref pvTitle);

// アーティスト名を取得
propertyStore->GetValue(PInvoke.PKEY_Music_Artist, out var pvArtist);
var artist = new string(pvArtist.Anonymous.Anonymous.Anonymous.calpwstr.pElems[0]);
PInvoke.PropVariantClear(ref pvArtist);

// 参照カウンタを減らす
propertyStore->Release();

正直なところ C++ で書いた方が楽な気がしてくるコードです。C# で -> 演算子を使ってメソッド呼び出しを書いたのは、何年振りかも分からないぐらいです。COM も RCW が使えないので、昔ながらの Release メソッドを明示的呼び出して、参照カウンタを減らす必要があります。

このコードを実行すると、以下のように曲情報が正しく取得できていることは確認できます。

これで動くことは確認出来ましたが、CsWin32 でマーシャリングを無効にするのは影響範囲が大きすぎるのと、出来るだけ unsafe なコードと生ポインターは使いたくないので、別の方法を試すことにしました。

P/Invote 定義を手書きして unsafe コードを避ける

色々と検討した結果として、Property System に必要な P/Invoke 定義だけを仕方なく手書きすることで、全体として unsafe コードを避けるという方法を選びました。

一番の問題となる PROPVARIANT 構造体は必要な分だけ定義することでシンプルにしました。FieldOffset を使った共用体の再現は値型に統一すれば問題ないので、以下のような定義にしています。

[StructLayout(LayoutKind.Explicit)]
public struct PROPVARIANT
{
    [FieldOffset(0)]
    public ushort vt;

    [FieldOffset(2)]
    public ushort wReserved1;

    [FieldOffset(4)]
    public ushort wReserved2;

    [FieldOffset(6)]
    public ushort wReserved3;

    [FieldOffset(8)]
    public IntPtr pwszVal;

    [FieldOffset(8)]
    public CALPWSTR calpwstr;
}

[StructLayout(LayoutKind.Explicit)]
public struct CALPWSTR
{
    [FieldOffset(0)]
    public uint cElems;

    [FieldOffset(8)]
    public IntPtr pElems;
}

文字列へのポインターは Marshal.PtrToStringUni を使うことで IntPtr から string に変換します。COM は基本的に LPWSTR なので Unicode として扱うメソッドを使えばよいです。

これで単純な文字列であればサクッと C# から扱えるように出来ます。

少し厄介なのが CALPWSTR 型の pElems です。名前からも想像がつくように文字列へのポインターの配列なので、実際には cElems 個のポインターが含まれています。C++ なら pElems[0] のように参照すれば良いですが、unsafe 以外ではそのように書けないので Marshal.ReadIntPtr を使って実装します。

これで実際の文字列を指すポインターを取得できるので、後は string に変換してあげればよいです。

同様の処理を CsWin32 のマーシャリングが有効な環境で実現したコードが以下の通りです。まだこっちの方が C# っぽさが残っていると思います。

var filePath = @"...";

// 指定したファイルの IPropertyStore を作成
PInvoke.SHGetPropertyStoreFromParsingName(filePath, null, GETPROPERTYSTOREFLAGS.GPS_DEFAULT, out IPropertyStore propertyStore);

// 曲名を取得
propertyStore.GetValue(PInvoke.PKEY_Title, out var pvTitle);
var title = Marshal.PtrToStringUni(pvTitle.pwszVal);
PInvoke.PropVariantClear(ref pvTitle);

// アーティスト名を取得
propertyStore.GetValue(PInvoke.PKEY_Music_Artist, out var pvArtist);
var artist = Marshal.PtrToStringUni(Marshal.ReadIntPtr(pvArtist.calpwstr.pElems));
PInvoke.PropVariantClear(ref pvArtist);

// RCW のカウントを減らす
Marshal.ReleaseComObject(propertyStore);

このコードを実行してみると、ファイルから正しく曲情報が取得できていることが確認出来ます。

今回は出来るだけ unsafe を使いたくなかったので Marshal クラスのメソッドを使って実現しましたが、大人しくマーシャリング無しでポインターを触った方が分かりやすいと思う人もいると思います。

この辺りは好みに寄りそうな気がするので、それぞれの使い方を知っておいて損はなさそうです。