SwiftでiTunes ライブラリファイルを編集するMac OS X アプリを作ってみた
一昨日のMP3ファイルのID3タグを一度に変更できるツールを作ってみた に続き、iTunes ライブラリファイル (iTunes Music Library.xml) を編集するMac OS X アプリをSwiftで作ってみたので、簡単にSwift言語とQuickというBDDフレームワークに付いて書きます。
コードはGitHubに置きました 。Xcode6 beta6で動作確認しています
このアプリの使い方
起動するとTunes ライブラリファイルiTunes Music Library.xmlを読み込み上のような画面が表示されます。Alubm Title名を入力しSearchボタンを押すと、そのアルバムの曲がテーブルに表示されます。ここでタイトルを変更したい場合は、AmazonでCDを検索し下のような曲名一覧をコピーし、テキストビューにペーストしてReplaceボタンを押すと、アプリ内の曲名情報が更新されます。そしてメーニューのSaveを選択するとデスクトップに更新されたiTunes Music Library.xmlファイルと 一昨日ツールで使う曲名情報のファイル MpegTitleList.txt が保存されます。
注意 このアプリにはバグがあるかもしれませんので、実際に使う際には絶対に iTunes Library.itl , iTunes Music Library.xml のバックアップを取ってから使って下さい m(__)m
解説
iTunes Music Library.xml に付いて
iTunesで管理している曲の情報は iTunes Library.itl ファイルで管理されていますが、その内容をXML形式で書き出したファイルが iTunes Music Library.xml です。iTunes Library.itlの全ての情報は含まれていないようですが、ほぼ全てと考えて良いと思います。iTunes ライブラリおよびプレイリストを作成し直す方法 に iTunes Library.itl ファイルが壊れてしまった際に iTunes Music Library.xml から復旧する方法が書かれています。
iTunes Music Library.xmlは NextSTEP由来のオブジェクトをシリアライズしたplistファイルで今風に言えばJSONみたいなものです、Mac OS X, iOSで設定情報を保存するによく使われています。 詳細はWikipedaのプロパティリスト したがって、Mac OS X, iOSのCocoaフレームワークのクラス(例 NSDictonary)から簡単に読み書きできます。
ITunesLibraryクラス
iTunes Music Library.xmlの読み書き、検索、置換を行うクラスです。Swift言語はC, Objectve-C, Java, Rubyなどの言語になれている人ならコードの大部分は理解できると思います。ここでは Swiftらしい部分のみ解説してみます。
まずは内部で使う型を定義しています。 Swiftでは構造体が使えるので簡単なデータ構造は struct で定義するのがお手軽です。また struct にはメソッドが定義できるので、ここではデバッグ等で便利なように descriptionメソッドを定義しています(しかし現在はdescriptionメソッドを明示しないと行けません!?)
import Cocoa struct MpegFileTitle : Printable { let path: String let title: String var description: String { get {return "path: \(self.path) title: \(self.title)"} } // Not work as "Swift Standard Library Reference" } typealias TrackId = Int
定数(class var 〜 { get 〜})の定義とインスタンス変数の定義(var 〜)。Swiftのクラスに対しての定数は書けない(インスタンスから参照出来る定数は let で書けます)ので参照のみのクラスComputed Propertiesを使っています。(もっと良い方法があるかも、ちなみに class let と書くとnot yet supportedエラーになります)
class ITunesLibrary: NSObject { class var LibraryXmlFileName: String { get { return "iTunes Music Library.xml" } } class var MpegTitleFileName: String { get { return "MpegTitleList.txt" } } var libaryDict: NSMutableDictionary = [:] var mpegTitleList: [MpegFileTitle] = []
XmlFilePath()はclass funcから始まるのでクラスメソッドです
class func XmlFilePath() -> String { return NSHomeDirectory() + "/Music/iTunes/iTunes Music Library.xml" }
さて次は、iTunes Music Library.xmlの読み書きするメソッドです。 Swiftの特徴の一つOptionalが使われています。Swiftは強い型付けの静的言語で、通常に宣言した変数は nil (値無し)には出来ません。nilになる可能性のある変数は、型の後ろに ? を付けて宣言します(または !を付ける、少し動作が違います)。また、コードの中で変数に格納されたオブジェクトのメソッドを呼び出す際には、nil でないことを保証するコードにする必要があります。CやObjective-Cに慣れているととても面倒に感じますが、nullポインターエラーにならない安全なコードがかけます。ただし、変数名! と書くと面倒な処理を省略できますが、値がnilの場合は実行時エラーになります。
loadの処理はNSData.dataWithContentsOfFile()メソッドでファイルを読み込み、NSPropertyListSerialization.propertyListWithData()メソッドでplist(XML)をSwiftのオブジェクトに変換しています。saveはその逆の操作をしています。
ちなみに、 NSDictionary(contentsOfFile: path)で直接plistファイルを読み込めますが、エラーがあった場合のエラー情報が取得出来ないのでこのようにしました。 詳しくはこちら
またsaveの中では、曲名情報のファイル(mpegTitlesFile)の作成ではArray#mapやString#joinなどの便利なメソッドを使っています。詳しくはこちら
func load(libraryXmlPath: String) -> NSError? { var error:NSError?; let data = NSData.dataWithContentsOfFile(libraryXmlPath, options: nil, error: &error) if data == nil { return error } let plist = NSPropertyListSerialization.propertyListWithData(data, options: 2, format: nil, error: &error) as NSMutableDictionary! if plist == nil { return error } libaryDict = plist mpegTitleList = [] return nil } func save(saveFolderPath: String) -> NSError? { var error:NSError?; let data = NSPropertyListSerialization.dataWithPropertyList(libaryDict, format: NSPropertyListFormat.XMLFormat_v1_0, options: 0, error: &error) if data == nil { return error } if !data.writeToFile(saveFolderPath.stringByAppendingPathComponent(ITunesLibrary.LibraryXmlFileName), options: NSDataWritingOptions.AtomicWrite, error: &error) { return error } var mpegTitlesFile = "\n".join(mpegTitleList.map({"\($0.path)\t\($0.title)"})) + "\n" if !mpegTitlesFile.writeToFile(saveFolderPath.stringByAppendingPathComponent(ITunesLibrary.MpegTitleFileName), atomically: true, encoding: NSUTF8StringEncoding, error: &error) { return error } return nil }
searchAlubm()は指定されたアルバムタイトルの曲を検索するメソッドです、単純に全ての曲を調べるので遅いかもしれません。前にも書いたようにSwiftは強い型付けの静的言語なので NSDictionaryのように何型のデータ(AnyObject!型)が入っているか判らないと処理が書けなので as 〜 で型をキャストしています。ただし、SwiftのStringとNSString等はある程度は自動的に変換されます。
SwiftのArrayやDictionaryは宣言時に型を指定しますが、NSArrayやNSDictionaryを扱う場合はキャストが必須です。キャストする際にもnil状態(Optiontal)を考えないといけません。ただしコンパイラー(Xcode)が警告やエラーを出すので助かります。
ここでも Array#sortのようにクロージャー(ブロック)を受け取るメソッドを使っています。
func searchAlubm(title: String) -> [TrackId] { var trackIds : Array<TrackId> = [] let tracks = libaryDict["Tracks"] as NSDictionary for (key, dict) in tracks { if (dict["Album"] as String?) == title { trackIds.append((key as String).toInt()!) } } trackIds.sort { $0 < $1 } return trackIds } func songTitle(id: TrackId) -> String { let tracks = libaryDict["Tracks"] as NSDictionary let track = tracks[String(id)] as NSDictionary return track["Name"] as String }
SwiftはObjective-C同様に正規表現の組み込みクラスやリテラルはありません。メソッドも引数がたくさんあり使いにくいです ^^;
class func parse(titlesChunk: String) -> [String] { var titles: [String] = [] let regex = NSRegularExpression.regularExpressionWithPattern("\\d+\\.\\s*(.*?)(\\s*試聴する)?$", options: nil, error: nil) for line in titlesChunk.componentsSeparatedByString("\n") { if var matches = regex?.firstMatchInString(line, options: nil, range: NSMakeRange(0, countElements(line))) { titles.append((line as NSString).substringWithRange(matches.rangeAtIndex(1))) } } return titles } func replaceTiles(ids: [TrackId], _ titles: [String]) -> NSError! { if ids.count != titles.count { return NSError.errorWithDomain("The number of titles is not the same as the number of tracks", code: 10001, userInfo: nil) } let tracks = libaryDict["Tracks"] as NSDictionary for id in ids { if tracks[String(id)] == nil { return NSError.errorWithDomain("TrackId \(id) not found", code: 10002, userInfo: nil) } } for var i = 0; i < ids.count; i++ { var track = tracks[String(ids[i])] as NSMutableDictionary track["Name"] = titles[i] mpegTitleList.append(MpegFileTitle(path: NSURL.URLWithString(track["Location"] as String).path!, title: titles[i])) } return nil } }
ITunesLibraryクラスのテストコード
テストはXcode標準のXCTestではなく、RSpec風に書ける QuickというBDDフレームワークを使いました。ただし8/22日時点で QuickはXcode6 beta6 に対応してないので フォークの bendjones/Quickを使いました。 (/Quick/Quickもbeta6対応されました 8/23)
最近はどの言語にもRSpec風に書けるツールがあり、新しい言語を学ぶにもテスト駆動開発が役に立つと思います。
コードはRSpecになれている方なら、だいたい読めると思います。Swift言語に付いてコメントすると
- 基底クラスのメソッドを置き換えるするには override を書く必要があります
- メソッドの戻り値を無視する事は出来ないので。 var _ = method() のように書きました(もっと良い書き方がるのかな?)
- ヒアドキュメントや、文字列を改行したりは出来ないので文字列を改行したい場合は + で連結して下さい
import Quick import Nimble class ITunesLibrarySpec: QuickSpec { override func spec() { var lib: ITunesLibrary = ITunesLibrary() var loadeErr: NSError! beforeEach { loadeErr = lib.load("./iTunesTitleRepairerTests/iTunesLibrary.xml") } describe("#load") { it("iTunesLibary XMLファイルを読み込める") { expect(loadeErr).to(beNil()) expect(lib.libaryDict.count).to(equal(9)) expect(((lib.libaryDict["Tracks"] as NSDictionary).allKeys as [String]).count).to(equal(4)) } it("iTunesLibary XMLファイルが存在しない場合はErrorを戻す") { let err = lib.load("./iTunesTitleRepairerTests/NOiTunesLibrary.xml") expect(err!.localizedDescription).to(contain("no such file")) } } describe("#save") { let NewLibFolder = "/tmp" let NewLibPath = NewLibFolder.stringByAppendingPathComponent(ITunesLibrary.LibraryXmlFileName) let MpegTitlesPath = NewLibFolder.stringByAppendingPathComponent(ITunesLibrary.MpegTitleFileName) let fileMan = NSFileManager.defaultManager() beforeEach { var _ = fileMan.removeItemAtPath(NewLibPath, error: nil) } it("ファイルに保存出来る") { expect(lib.save(NewLibFolder)).to(beNil()) expect(fileMan.fileExistsAtPath(NewLibPath)).to(beTruthy()) expect(fileMan.fileExistsAtPath(MpegTitlesPath)).to(beTruthy()) } it("変更内容が書き込まれている") { var _ = lib.replaceTiles([6257, 6259], ["名もない恋愛", "足ながおじさんになれずに"]) var _ = lib.save(NewLibFolder) var newLib: ITunesLibrary = ITunesLibrary() newLib.load(NewLibPath) expect(newLib.songTitle(6257)).to(equal("名もない恋愛")) } it("音楽ファイル名変更用shell scriptも書かれる") { var _ = lib.replaceTiles([6257, 6259], ["VOLARE (NEL BLU DIPINTO DI BLU) / ボラーレ", "SARAVAH! / サラヴァ!"]) var _ = lib.save(NewLibFolder) expect(NSString(contentsOfFile: MpegTitlesPath, encoding: NSUTF8StringEncoding, error: nil)).to(equal( "/Users/yy/Music/iTunes/iTunes Media/Music/高橋幸宏/Saravah!/01 AudioTrack 01.mp3\tVOLARE (NEL BLU DIPINTO DI BLU) / ボラーレ\n/Users/yy/Music/iTunes/iTunes Media/Music/高橋幸宏/Saravah!/02 AudioTrack 02.mp3\tSARAVAH! / サラヴァ!\n")) } } describe("#searchAlubm") { it("アルバム名で検索し曲名のTrackIDを戻す") { expect(lib.searchAlubm("A Day in the next life")).to(equal([5791, 5793])) } } describe("#songTitle") { it("指定されたTrackIDの曲名を戻す") { expect(lib.songTitle(5791)).to(equal("震える惑星(ほし)")) } } describe(".parse") { it("Amazonの曲名リストから曲名の配列を取得する") { let chunk = "1. VOLARE (NEL BLU DIPINTO DI BLU) / ボラーレ\t試聴する\n" + "2. SARAVAH! / サラヴァ!\t試聴する\n" + "1. 名もない恋愛\n" + "2. 足ながおじさんになれずに" expect(ITunesLibrary.parse(chunk)).to(equal( ["VOLARE (NEL BLU DIPINTO DI BLU) / ボラーレ", "SARAVAH! / サラヴァ!", "名もない恋愛", "足ながおじさんになれずに"])) } } describe("#replaceTiles") { it("指定された複数のidの曲名を置き換える") { let err = lib.replaceTiles([6257, 6259], ["VOLARE (NEL BLU DIPINTO DI BLU) / ボラーレ", "SARAVAH! / サラヴァ!"]) expect(err).to(beNil()) expect(lib.songTitle(6257)).to(equal("VOLARE (NEL BLU DIPINTO DI BLU) / ボラーレ")) expect(lib.songTitle(6259)).to(equal("SARAVAH! / サラヴァ!")) println(lib.mpegTitleList[0].description) } } } }
AppDelegateデリゲート
GUI操作に対応したコードは今回は全てAppDelegateクラスに書いてしまいました。iOSプログラミングをしたことがある方は、だいたいわかると思います。
- iOSに比べるとGUI要素(例 NSTableView, NSTextView)が高機能でいろいろと戸惑いました
- またメニューもiOSにはないで勉強になりました。詳細はここ
- Swiftには performSelectorメソッドがないので、少し後に行いたい処理はGCDを使いました。ヒントはいつものStackOverflow
import Cocoa class AppDelegate: NSObject, NSApplicationDelegate, NSTableViewDataSource, NSTableViewDelegate { @IBOutlet weak var window: NSWindow! @IBOutlet weak var albumTitle: NSTextField! @IBOutlet weak var songTable: NSTableView! @IBOutlet var newTitleText: NSTextView! var trackIds: [Int] = [] var iTunes: ITunesLibrary = ITunesLibrary() let NewFilesFolder = NSHomeDirectory() + "/Desktop" func applicationDidFinishLaunching(aNotification: NSNotification?) { performBlock(0.1) { self.openLibrary(ITunesLibrary.XmlFilePath()) } } func applicationWillTerminate(aNotification: NSNotification?) { } func numberOfRowsInTableView(tableView: NSTableView!) -> Int { return trackIds.count } func tableView(tableView: NSTableView!, objectValueForTableColumn tableColumn: NSTableColumn!, row: Int) -> AnyObject! { if tableColumn.identifier == "SequenceColumn" { return row + 1 } else { return iTunes.songTitle(trackIds[row]) } } @IBAction func openDocument(sender: AnyObject) { let openPanel = NSOpenPanel() openPanel.canChooseFiles = true if openPanel.runModal() == NSOKButton && openPanel.URLs.count == 1 { openLibrary((openPanel.URLs[0] as NSURL).path!) } } @IBAction func saveDocument(sender: AnyObject) { if let err = iTunes.save(NewFilesFolder) { showErrorAlert(err) } } @IBAction func searchPushed(sender: AnyObject) { trackIds = iTunes.searchAlubm(albumTitle.stringValue) songTable.reloadData() } @IBAction func replacePushed(sender: AnyObject) { let titles = ITunesLibrary.parse(newTitleText.string) if titles.count == 0 { return } if let err = iTunes.replaceTiles(trackIds, titles) { showErrorAlert(err) } else { songTable.reloadData() newTitleText.string = "" } } private func openLibrary(path: String) { if let err = iTunes.load(path) { showErrorAlert(err) } else { newTitleText.string = "" albumTitle.stringValue = "" albumTitle.enabled = true trackIds = [] songTable.reloadData() } } private func showErrorAlert(error: NSError!) { var alert = NSAlert(error: error) if alert.runModal() == NSAlertFirstButtonReturn { // NOP } } private func performBlock(deley: Double, _ closure: () -> ()) { dispatch_after( dispatch_time(DISPATCH_TIME_NOW, Int64(deley * Double(NSEC_PER_SEC))), dispatch_get_main_queue(), closure) } }