常駐させて曲が再生されるたびにツイートするMacアプリの開発

年末になって一つアプリの開発を始めたので記事にしておく
以前作ったAppleScriptで再生中の曲をツイートするものがHigh Sierra 10.13で使えなくなってしまったので

最初に

以前作ったものはTwitter Scripterに依存していたがこのTwitter Scripterのver 1.03が使えなくなってしまったのでTweetする機能も内包してアプリを作ろうと思った

開発

曲の情報の取得

Twitter APIを扱うのにAppleScriptよりもSwiftで書いたMac Appの方がいいだろうということで、まずは曲の情報取得方法を考えることに

通知から取得する

曲を再生や停止するたびに内部では通知が飛んでいるらしいのでそれを捕まえて曲の情報を手に入れてみる
その仕組みにすれば再生時に同時ツイートもできるということでググった
NSDistributedNotificationCenterというものを使えばいいらしい
現在、Swiftは3.0もしくは4.0でDistributedNotificationCenterにクラス名が変わってるのでそこを直して以下のようにしてiTunesの通知を取得する
それから High Sierra からなのか Yosemite からなのかわからないが、別のアプリの通知を捕まえるために .entitlements に Array でcom.apple.security.temporary-exception.apple-eventsの項目を追加して item として利用したいアプリの Identifier、ここではcom.apple.iTunesを追加する必要がある

// DistributedNotificationCenter を初期化
let dnc = DistributedNotificationCenter.default()
dnc.addObserver(self,
                selector: #selector(NowPlayingTweet.nowPlaying(_:)),
                name: NSNotification.Name(rawValue: "com.apple.iTunes.playerInfo"),
                object: nil)
@objc func nowPlaying(_ notification: NSNotification) {
    // .userInfo に曲の情報が Dictionary で入ってるので Dictionary型の変数に格納
    let musicInfo: Dictionary = notification.userInfo!

    // 再生した時のみ機能するように Player State を String型にキャストして Playing でなければ処理を終了
    if (musicInfo["Player State"]! as! String != "Playing") {
        return
    }

    // 曲名
    let trackTitle: String = musicInfo["Display Line 0"] as! String

    // アーティスト名
    let trackArtist: String = musicInfo["Artist"] as! String

    // アルバム名
    let trackAlbum: String = musicInfo["Album"] as! String

    // Persistent ID (曲の一意なID?)
    let trackPersistentId: UInt = UInt.init(bitPattern: musicInfo["PersistentID"] as! Int)
    let hexPersistentId = String(trackPersistentId, radix: 16)

    // Library Persistent ID (ライブラリの一意なID?)
    let libPersistentId: UInt = UInt.init(bitPattern: musicInfo["Library PersistentID"] as! Int)
    let hexLibPersistentId: String = String(libPersistentId, radix: 16)

    // 曲ファイルのパス
    let m4aFilePath: String = musicInfo["Location"] as! String

    // ...
}

DistributedNotificationCenter のオブザーバーでは selector に objc から扱える関数が必要なので@objcをつけた nowPlaying 関数を宣言する
関数の引数には NSNotification を受け取るようにして受け取った通知の userInfo から曲の情報を受け取った
これであとはツイートの文として投げればいいだけなのであとはアートワーク(カバー画像)だがこの情報を使って画像を取得しようとしても存在しないものがあった
再生中の曲のアートワークは/Users/<User Name>/Music/iTunes/Album\ Artwork/Cache/<Library Persistent ID>以下に.itcファイルとして保存されている。が、トラックが2以降の曲は基本的にアートワークが保存されていない
また、アートワークが .m4aファイル自体に埋め込まれていない曲もある
なので先のコードで取得した情報だけではアートワークを確実に取得することができない

iTunes で再生中の曲から取得する

AppleScript でやっていたように iTunes で再生中の曲を取得してみる
方法としては AppleScript を Bridge する
Objective-C の時には Scripting Bridge というものがあったらしいが Swift に作り直してはいないようなので@objc protocolなどを宣言して一からラッピングする必要があるらしい。めんどくさい
ここでSwiftScriptingという便利なものを拝借する(ライセンスが記述されていないが書き換えるわけではないのでコピーライトだけ書いておけばいいだろう……)

とりあえず clone してFrameworksディレクトリでbuild.shを実行する
Build ディレクトリに .framework ができるのでプロジェクトから ScriptingUtilities と iTunesScripting を参照するようにして embedded にも加える
あとは以下のようにimportを加えてアートワークを取得する

import ScriptingUtilities
import iTunesScripting
@objc func nowPlaying(_ notification: NSNotification) {
    // ...

    // iTunesApplication を初期化
    let iTunes: iTunesApplication = ScriptingUtilities.application(name: "iTunes") as! iTunesApplication
    // Identifier を直接指定する方法
    //let iTunes = ScriptingUtilities.application(bundleIdentifier: "com.apple.iTunes") as! iTunesApplication

    // 再生中の曲の情報
    let currentTrack: iTunesTrack = iTunes.currentTrack!

    // アートワークは SBElementArray型の配列になってる
    // 手動ツイートする時に任意のアートワークを選べるようにもできるように配列で一度格納して1つ目を取り出すように
    // [iTunesArtwork] にキャストして変数に格納する
    let trackArtworks: [iTunesArtwork] = currentTrack.artworks!() as! [iTunesArtwork]
    let trackArtwork: NSImage? = trackArtworks[0].data
}

一応ここで曲名なども取得できるが通知で取得しているのでここで取得するようにはしない。手動ツイートを実装すれば共通化のためにここから取得してもいいだろう

ツイートする

これができなければ仕方がないので Swift でツイートするためのライブラリ/フレームワーク/APIラッパーを探すことに
見つけたのが mattdonnelly/Swifter というフレームワーク
Carthage を使ってプロジェクトに取り込む

ビルドできたらプロジェクトで参照するようにして import する

import SwifterMac
// Swifter 初期化
let swifter = Swifter(consumerKey: CONSUMER_KEY, consumerSecret: CONSUMER_SECRET)

func tweeting(text: String, artwork: NSImage?) {
    let failureHandler: Swifter.FailureHandler = { error in
        NSLog(error.localizedDescription)
    }

    // 認証後にツイートする
    let tweetHandler: Swifter.TokenSuccessHandler = { accessToken, response in
        self.tweet(text: text, image: self.imageJPEGRepresentation(artwork, compressionQuality: 1.0)!)
    }

    if (self.swifter.client.credential?.accessToken == nil) {
        // 認証してなければここで認証
        self.swifter.authorize(with: URL(string: "nowplayingtweet://success")!, success: tweetHandler, failure: failureHandler)
    } else {
        // 認証済みなのでそのままツイート
        self.tweet(text: text, image: self.imageJPEGRepresentation(artwork, compressionQuality: 1.0)!)
    }
}

// ツイートする関数
func tweet(text: String, image: Data) {
    self.swifter.postTweet(status: text, media: image, success: nil, failure: { error in
        NSLog(error.localizedDescription, "failed to tweet")
    })
}

クラスプロパティとしてswifterを宣言してtweeting関数で認証済みか確認、tweet関数でツイート
nowplayingtweet://success でアプリを開くようにするために AppDelegate に以下を追加

import Swifter

// ...
func applicationDidFinishLaunching(_ aNotification: Notification) {
    // Insert code here to initialize your application
    NSAppleEventManager.shared().setEventHandler(self, andSelector: #selector(AppDelegate.handleEvent(_:withReplyEvent:)), forEventClass: AEEventClass(kInternetEventClass), andEventID: AEEventID(kAEGetURL))
}
    
@objc func handleEvent(_ event: NSAppleEventDescriptor!, withReplyEvent: NSAppleEventDescriptor!) {
    Swifter.handleOpenURL(URL(string: event.paramDescriptor(forKeyword: AEKeyword(keyDirectObject))!.stringValue!)!)
}

ここは Swifter のデモアプリ、ほとんどそのままなので何をしてるのかよくわかってない()
デモアプリと変えているのはここで URL scheme を決めずに Info.plist に記述していること
Info.plist での URL scheme 設定はここでは書かない

画像

しれっとまだ書いてない関数を使ってアートワークを投稿するようにしているが、NSImage型を Data型にしないといけないので次の関数を追加する

func imageJPEGRepresentation(_ image: NSImage?, compressionQuality: CGFloat) -> Data? {
    // nil を投げてきたら nil を返す
    if (image == nil) { return nil }

    let dict: Dictionary = [NSBitmapImageRep.PropertyKey.compressionFactor: NSNumber.init(value: Float(compressionQuality))]

    let imageRep: NSBitmapImageRep = NSBitmapImageRep(data: image!.tiffRepresentation!)!

    return imageRep.representation(using: NSBitmapImageRep.FileType.jpeg, properties: dict)
}

iOSの UIImage型を NSData型に変換する UIImageJPEGRepresentation の NSImage版みたいなもの?

これで起動して最初に再生したタイミングでの認証を除いてそのあとは曲を再生したり変わったりするたびにツイートされる

とりあえず今日はここまで。まだ git init すらしてないので公開するのはしばらく先

それから

良いお年を

Leave a Reply

Your email address will not be published. Required fields are marked *