radikoとらじるを予約録音するスクリプト連携
rec_radiko.shとrec_radiru.shが思いどおりに機能するようになり、予約録音の下準備は完了した。あとは、これを好みの時間に起動するスクリプトを用意すれば、予約録音が成立するはず。好みの時間に好みの番組を聞く自由を手に入れたい。
タイマー管理の仕組みに、launchdを使うか、iCal(カレンダー)のアラームを使うか悩んだが、pmsetによるスリープ解除との連携を考えた場合、launchdを使っていた方が苦労が少なくなりそうなので、当初の予定どおりlaunchdとpmsetで行くことにした。よって、目指すものは「確実にタイマー予約するまでの格闘」で苦労した手作業を、できる限り素早く簡単に設定するスクリプトである。
仕様
launchd.plistの設定
- プログラムからLingonは使えないので、defaultsコマンドを使ってやってみた。
$ plist_path=$HOME/Library/LaunchAgents/com.bebekoubou.rec_radikoru_1_1700_60_FMJ.plist; $ defaults write $plist_path Label 'com.bebekoubou.rec_radikoru_日_17:00_60_J-WAVE'; $ defaults write $plist_path StartCalendarInterval -dict Hour -int 17 Minute -int 0 Weekday -int 0; $ defaults write $plist_path ProgramArguments -array /usr/bin/caffeinate /usr/local/bin/rec_radiko.sh -o radikoru/ -t 3600 FMJ
曜日→日付変換
- pmset scheduleに曜日指定はなく日付指定のみなので、現在の日時から次の日曜日の日付を求めるのだ。
$ date -v+sun -v+0w -v17H -v0M -v0S -v-30S "+%m/%d/%y %H:%M:%S"
02/03/13 16:59:30
-v+sun | 次の日曜日の、 |
---|---|
-v+0w | 0週間後の、(日曜の17時以降に予約する時は-v+1wとする) |
-v17H -v0M -v0S | 17時00分00秒の、 |
-v-30S | 30秒前を、(スリープの復帰に時間がかかるため30秒前に設定) |
"+%m/%d/%y %H:%M:%S" | 例: "02/03/13 16:59:30"の書式で出力する。(2月3日2013年 16時59分30秒) |
pmset scheduleの設定
- 上記で求めた日付書式はそのままpmset schedule wakeの設定に使える。
- ちなみに、pmset scheduleの設定には、管理者権限が必要だ。
$ sudo pmset schedule wake "02/03/13 16:59:30" Password: $ pmset -g sched Scheduled power events: [0] wake at 02/03/13 16:59:30
ひとまずこれで、ディスプレイを閉じていない限り、スリープから復帰してサウジ・サウダージの録音が始まるはずである。
繰り返しのスリープ解除
- サウジ・サウダージは毎週日曜日に放送される。だから毎週録音したい。
- launchdの設定は、日曜日の17時に繰り返す設定なので、このままでOK。
- ところが、pmset scheduleは単発スケジュールである。
- "02/03/13 16:59:30"のスリープ解除を実行したら、消えてしまうのだ。
- 翌週以降のサウジ・サウダージは幸運にもスリープしていなければ録音できるが、
- もしスリープしていたら、そのまま眠り続けてlaunchdの予約は実行されないのだ。
困った...。
- 悩みながら辿り着いた結論は「単発スケジュールを毎週繰り返すには、もう一度設定するしかない」ということ。
- 定期的、あるいは録音終了後に、もう一度pmset scheduleで次のスリープ解除の設定をする必要がある。
- そこで、launchd.plistのファイル名の情報を見て、pmset scheduleを同期させるスクリプトを書いてみた。
- ファイル名は、..._1_1700_60_FMJ.plist のような予約日時にちなんだ名前にしてある。
#!/bin/sh pmdate_from_w_hhmm() { weekday=(sun mon tue wed thu fri sat sun) w=`expr $1 - 1` hhmm=`echo $2|sed s/://` hh=`date -j -f "%H%M" $hhmm +%H` mm=`date -j -f "%H%M" $hhmm +%M` current_date=`date +%s` preset_date=`date -v+${weekday[$w]} -v${hh}H -v${mm}M -v0S +%s` n=`expr $current_date / $preset_date` date -v+${weekday[$w]} -v+${n}w -v${hh}H -v${mm}M -v0S -v-30S "+%m/%d/%y %H:%M:%S" } add_schedule() { pmset -g sched|grep -q "wake at $1" || pmset schedule wake "$1" } rm_schedule() { pmset -g sched|grep -q "wake at $1" && pmset schedule cancel wake "$1" } cd `dirname $0` # launchd.plistを見て、不足するscheduleを追加する launchd_plist_path=`ls $HOME/Library/LaunchAgents/com.bebekoubou.rec_radikoru_* 2>/dev/null` for f in $launchd_plist_path do w=`echo $f|cut -d_ -f3` hhmm=`echo $f|cut -d_ -f4` pmformat="`pmdate_from_w_hhmm $w $hhmm`" add_schedule "$pmformat" launchds+="${pmformat}\n" done # launchd.plist存在しない、余分なscheduleを削除する _IFS="$IFS"; IFS=$'\n'; schedules="`pmset -g sched|grep '^\W\['|cut -d' ' -f6-7`" for s in $schedules do echo $launchds|grep -q "$s" || rm_schedule "$s" done IFS="$_IFS" # launchdと同期されたscheduleを表示する pmset -g sched
- 上記スクリプトをschedule_sync.shとして保存して、実行権限を付与しておいた。
$ chmod +x schedule_sync.sh
- これでschedule_sync.shを実行するたびに、launchd.plistとpmset scheduleの設定が同期されるはず。
パスワードなしで管理者権限で実行する方法
- ところが、同期されなかった...。(pmset scheduleの実行には、管理者権限が必要なので)
- 正確には管理者権限で実行すれば、launchd.plistとpmset scheduleは同期された。
- でも、定期的あるいは録音終了後に実行する必要のあるschedule_sync.shで、毎回管理者パスワードの入力を求めるのはナンセンス。
- 予約設定時のパスワード入力くらいは我慢できるけど、その後も毎週パスワード入力を求めていたら、間違いなくユーザーに嫌われる。
- さらに、サウジ・サウダージ以外にもいくつも予約したら、毎日、さらには日に何度もパスワード入力を求められる可能性もあるのだ。
- そんなパスワードばかりの予約録音システムなんて、絶対欲しくない。
- この問題を解決する方法として、UNIXにはsビットが用意されている。
- sビットが設定されたコマンドであれば、誰が実行してもそのコマンドの所有者の権限で実行される。
- もしそのコマンドの所有者がrootであり、sビットが設定されていれば、root権限で実行されるのだ!
素晴らしい!と同時に、それは危険でもある。
- だから、rootのsビットを設定する場合は、十分注意しなければならない。
- そのコマンドに常にroot権限を与えてしまっても悪用されないか、
- 悪意のある人に利用された場合の最悪の状況を想像しておくべきである。
スクリプトのrootは許可されない
- 十分検討しました、よってschedule_sync.shにrootのsビットを設定ます、と以下のようにやってもroot権限は有効にならない。
$ sudo chown root:wheel schedule_sync.sh $ sudo chmod u+s schedule_sync.sh $ ls -l schedule_sync.sh –rwsr-xr-x@ 1 root wheel 1245 1 28 10:24 schedule_sync.sh*
- 見た目はsビットが設定(実行権限がsになっている)されているが、これを実行してもroot権限では実行されないのだ...。
- そう、スクリプトのsビットが設定されていても、それは有効にならないのだ。だぶん、セキュリティ上の理由だと思う。
- sビットが有効になるのは、c言語などからコンパイルされた、実行可能なバイナリファイルである必要がある。
add_scheduleとrm_scheduleコマンドを作る
- Command Line Toolのプロジェクトを立ち上げて、以下のソースコードでpmset schedule wakeコマンドを実行できた。
- コメント行に変更すると、pmset schedule cancel wakeになる。
#import <Foundation/Foundation.h> int main(int argc, const char * argv[]) { @autoreleasepool { NSTask *task = [[NSTask alloc] init]; NSPipe *pipe = [[NSPipe alloc] init]; NSString *datetime = [NSString stringWithUTF8String:argv[1]]; [task setLaunchPath: @"/usr/bin/pmset"]; [task setArguments: [NSArray arrayWithObjects: @"schedule", @"wake", datetime, nil]]; #[task setArguments: [NSArray arrayWithObjects: @"schedule", @"cancel", @"wake", datetime, nil]]; [task setStandardOutput: pipe]; [task launch]; NSFileHandle *handle = [pipe fileHandleForReading]; NSData *data = [handle readDataToEndOfFile]; NSString *result = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; NSLog(@"%@", result); // insert code here... NSLog(@"\"%s\"", argv[1]); } return 0; }
以下のページたいへん参考になりました。感謝です!
- ビルドして生成されたadd_scheduleとrm_scheduleについては、所有者をrootにして、sビットを立てておいた。
$ sudo chown root:wheel add_schedule rm_schedule $ sudo chmod u+s add_schedule rm_schedule
launchdで複数コマンドを実行する
- 常にroot権限で実行してくれるadd_schedule、rm scheduleコマンドは完成した。
- あとは、schedule_sync.shの必要な部分をこれらのコマンドで置き換えればいいのだ。
$ diff -u /Users/bebe/Desktop/schedule_sync_1.sh /Users/bebe/Desktop/schedule_sync_2.sh --- /Users/bebe/Desktop/schedule_sync_1.sh 2013-01-28 13:10:28.000000000 +0900 +++ /Users/bebe/Desktop/schedule_sync_2.sh 2013-01-28 13:10:54.000000000 +0900 @@ -13,11 +13,11 @@ } add_schedule() { - pmset -g sched|grep -q "wake at $1" || pmset schedule wake "$1" + pmset -g sched|grep -q "wake at $1" || ./add_schedule "$1" } rm_schedule() { - pmset -g sched|grep -q "wake at $1" && pmset schedule cancel wake "$1" + pmset -g sched|grep -q "wake at $1" && ./rm_schedule "$1" } cd `dirname $0`
- schedule_sync.shを実行するタイミングは、rec_radiko.sh、rec_radiru.shで録音が完了した直後である。
- よって、launchdの処理として以下のようなコマンドを実行したいのだ。
/usr/bin/caffeinate /usr/local/bin/rec_radiko.sh -o radikoru/ -t 3600 FMJ ; /usr/local/bin/schedule_sync.sh
- ところが ; が曲者である。launchdの中では1コマンドとその引数しか指定できない、ようなのだ。
- 引数として ; を設定しても、それより右側のschedule_sync.shは実行されない事態になっている...。
- 暫し、どうするべきか悩んだが、言われてみれば簡単だった。
$ plist_path=$HOME/Library/LaunchAgents/com.bebekoubou.rec_radikoru_1_1700_60_FMJ.plist; $ defaults write $plist_path Label 'com.bebekoubou.rec_radikoru_日_17:00_60_J-WAVE'; $ defaults write $plist_path StartCalendarInterval -dict Hour -int 17 Minute -int 0 Weekday -int 0; $ defaults write $fpath ProgramArguments -array /usr/bin/caffeinate sh -c "Scripts/rec_radiko.sh -o radikoru/ -t 3600 FMJ; /usr/local/bin/schedule_sync.sh"
- 複数コマンドの場合は、sh -c にコマンドを一括して渡してしまって、連続するコマンドテキストとして実行してしまえばよいのである。
- よっぽどrec_radiko.shの最後にshedule_sync.shを追加してしまおうかと思ったが、辛うじて思いとどまることでこの書き方を学んだ。
- 本来、録音することの処理の中に、スケジュールの追加・削除の処理を混ぜるべきではないのだ。
- これで録音が完了すると、すぐまた次のスケジュールが追加されることになる。
- つまり、単発スケジュールで毎週繰り返すスケジュールを組めることになるのだ!
AppleScriptアプリケーションとしてまとめる
- 以上の技を組み合わせて、AppleScriptアプリケーションとしてまとめてみた。
property NEW_ITEM : "-- 新規作成 --"
global Area, StationNames, StationIDs
on run
set Area to area_info() set StationNames to station_names() set StationIDs to station_ids() repeat
list_preset() end repeat
end run
on list_preset() set res to choose from list presets() & NEW_ITEM with title "予約リスト(エリア: " & Area's |name| & ")" cancel button name "閉じる"
if res = false then
error number -128 --強制終了
else if res = {NEW_ITEM} then
try
new_preset() on error msg number num
if num ≠ -128 then error msg number num
end try
else
try
edit_preset(res as text) on error msg number num
if num ≠ -128 then error msg number num
end try
end if
end list_preset
on new_preset() set datetime to (current date) + minutes
set w to weekday_jp(datetime) set h to (datetime)'s hours
set m to digit2((datetime)'s minutes) activate
"曜日 時刻 録音時間(分) を入力してください。
半角スペースで区切ります。
例)" & w & "曜の" & h & ":" & m & "から5分間の録音する予約"
set res to display dialog result default answer w & " " & h & ":" & m & " 5" buttons {"キャンセル", "(選局)追加..."} default button 2 with title "タイマー予約の追加"
set station_name to choose from list StationNames
if station_name = false then return
add_launchd_plist(res's text returned, station_name) pmset_schedule_sync() end new_preset
on edit_preset(w_hm_m) set {w, hm, m, station_name} to split(w_hm_m, tab) activate
"曜日 時刻 録音時間(分) を修正してください。
半角スペースで区切ります。
例)" & w & "曜の" & hm & "から" & m & "分間の録音する予約"
set res to display dialog result default answer join({w, hm, m, station_name}, space) buttons {"キャンセル", "削除", "(選局)編集..."} default button 3 with title "タイマー予約の削除・編集..."
if res's button returned = "(選局)編集..." then
set new_station_name to choose from list StationNames default items station_name with title res's text returned & "の編集..."
if new_station_name = false then return
set new_fname to plist_fname(res's text returned, new_station_name) set old_fname to plist_fname(w_hm_m, station_name) if new_fname = old_fname then return
add_launchd_plist(res's text returned, new_station_name) end if
rm_launchd_plist(w_hm_m, station_name) pmset_schedule_sync() end edit_preset
--エリア情報を返す
on area_info() split(do shell script rec_radiko_radiru_path("radiko") & space & "-o radikoru/ -a|tail -1", ",") --{"JP13", "東京都", "tokyo Japan"}
{|id|:result's item 1, |name|:result's item 2, alphabet:result's item 3} end area_info
--station_idリストを返す(radiruのNHK含む)
on station_ids() "curl -s http://radiko.jp/v2/station/list/" & Area's |id| & ".xml|xpath //id 2>/dev/null|sed -e 's/<id>//g' -e 's/<\\/id>/,/g'"
do shell script result
split(result, ",")'s items 1 thru -2
{"NHK-R1", "NHK-R2", "NHK-FM"} & result
end station_ids
--station_nameリストを返す(radiruのNHK含む)
on station_names() "curl -s http://radiko.jp/v2/station/list/" & Area's |id| & ".xml|xpath //name 2>/dev/null|sed -e 's/<name>//g' -e 's/<\\/name>/,/g'"
do shell script result
split(result, ",")'s items 1 thru -2
{"NHK-R1", "NHK-R2", "NHK-FM"} & result
end station_names
--予約リストを返す
on presets() try
do shell script "ls ~/Library/LaunchAgents/com.bebekoubou.rec_radikoru_*"
set fpaths to split(result, return) set res to {} repeat with f in fpaths
do shell script "defaults read " & f & " Label|cut -d_ -f3-|tr '_' '\\t'"
set res's end to encode_u(result) end repeat
res
on error msg number num
if num = 1 then
{} else
error msg number num
end if
end try
end presets
--J-WAVE ---> FMJ
on station_id_from(station_name) StationIDs's item offset_in(StationNames, station_name) end station_id_from
--日 17:00 60 J-WAVE ---> 1_1700_60_FMJ_12345
on plist_fname(w_hm_m, station_name) set {w, hm, m} to split(w_hm_m, {tab, space, " "}) --{"日", "17:00", "60"}
join({weeknum(w), digit2(date (hm)'s hours) & digit2(date (hm)'s minutes), m, station_id_from(station_name)}, "_") end plist_fname
--日 17:00 60 J-WAVE ---> com.bebekoubou.rec_radikoru_日_17:00_60_J-WAVE
on plist_label(w_hm_m, station_name) set {w, hm, m} to split(w_hm_m, {tab, space, " "}) --{"日", "17:00", "60"}
join({"com.bebekoubou.rec_radikoru", w, digit2(date (hm)'s hours) & ":" & digit2(date (hm)'s minutes), m, station_name}, "_") end plist_label
--日 17:00 60 J-WAVE ---> -dict Hour -int 17 Minute -int 00 Weekday -int 0
on plist_start_calendar_interval(w_hm_m) set {w, hm, m} to split(w_hm_m, {tab, space, " "}) --{"日", "17:00", "60"}
"-dict Hour -int " & date (hm)'s hours & " Minute -int " & date (hm)'s minutes & " Weekday -int " & weeknum(w) - 1
end plist_start_calendar_interval
--日月火水木金土
--1234567を返す
on weeknum(obj) if obj's class = date then
obj's weekday as number
else if obj's class = text then
try
obj as number
on error
offset of obj in "日月火水木金土"
end try
else if obj's class = integer then
obj
end if
end weeknum
--weekday_jp(current date) --> 日
on weekday_jp(datetime) "日月火水木金土"'s item weeknum(datetime) end weekday_jp
--launchd_plistに予約時間をセットする
on add_launchd_plist(w_hm_m, station_name) set {w, hm, m} to split(w_hm_m, {tab, space, " "}) --{"日", "17:00", "60"}
set plist_path to launchd_plist_path(plist_fname(w_hm_m, station_name)) "fpath=" & plist_path & ¬ ";defaults write $fpath Label " & quoted form of plist_label(w_hm_m, station_name) & ¬ ";defaults write $fpath StartCalendarInterval " & plist_start_calendar_interval(w_hm_m) & ¬ ";defaults write $fpath ProgramArguments -array " & "/usr/bin/caffeinate sh -c " & ¬ quoted form of (rec_radiko_radiru_path(station_name) & " -o radikoru/ -t " & m * 60 & space & station_id_from(station_name) & ";" & schedule_sync_path()) do shell script result
do shell script "launchctl unload " & plist_path
do shell script "launchctl load " & plist_path
end add_launchd_plist
--launchd_plistを削除する
on rm_launchd_plist(w_hm_m, station_name) set plist_path to launchd_plist_path(plist_fname(w_hm_m, station_name)) do shell script "launchctl unload " & plist_path
do shell script "[ -r " & plist_path & " ] && rm " & plist_path
end rm_launchd_plist
--launchdとscheduleを同期する
on pmset_schedule_sync() do shell script schedule_sync_path() end pmset_schedule_sync
--予約に応じて、launchd_plistのパスを返す
on launchd_plist_path(str) "$HOME/Library/LaunchAgents/com.bebekoubou.rec_radikoru_" & str & ".plist"
end launchd_plist_path
--選局に応じて、rec_radikoかrec_radiruのパスを返す
on rec_radiko_radiru_path(station_name) if (station_name as text) starts with "NHK-" then
(path to resource "Scripts/rec_radiru.sh")'s POSIX path
else
(path to resource "Scripts/rec_radiko.sh")'s POSIX path
end if
end rec_radiko_radiru_path
--pmset scheduleとlaunchdを同期するschedule_sync.shのパスを返す
on schedule_sync_path() (path to resource "Scripts/schedule_sync.sh")'s POSIX path
end schedule_sync_path
--リストに変換する(テキストを区切り記号で分割)
on split(src_text, delimiter) set last_delimiter to AppleScript's text item delimiters
set AppleScript's text item delimiters to delimiter
set res to src_text's text items
set AppleScript's text item delimiters to last_delimiter
res
end split
--テキストに変換する(リストを区切り記号で接続)
on join(src_list, delimiter) set last_delimiter to AppleScript's text item delimiters
set AppleScript's text item delimiters to delimiter
set res to src_list as text
set AppleScript's text item delimiters to last_delimiter
res
end join
--src_listにおけるfind_itemのインデックスを返す
on offset_in(src_list, find_item) set i to 0
repeat with a_item in src_list
set i to i + 1
if a_item as list is find_item as list then return i
end repeat
0
end offset_in
--不足する桁を0で埋めた2桁の数字に変換する
on digit2(n) do shell script "printf %02d " & n
end digit2
--ユニコードポイントを含む文字列を、判読可能な文字列にエンコードする
on encode_u(str) --do shell script "ruby -e \"puts(" & quoted form of str & ".gsub(/\\\\\\\\u([0-9a-f]{4})/){[\\$1.hex].pack('U')})\""
do shell script "python -c \"print u" & quoted form of str & ".encode('utf-8')\""
end encode_u
- 上記AppleScriptをファイルフォーマット:アプリケーションとして保存しておいた。
- アプリケーション名:rec_radikoru.app(radiko+radiru=radikoru...安易である)
- rec_radikoru.app/Contents/Resources/以下には、下記のスクリプトも含めておく。
- add_schedule
- rm_schedule
- rec_radiko.sh
- rec_radiru.sh
- schedule_sync.sh
- すべてのコマンドには、実行権限を与えておいた。
$ chmod +x add_schedule rm_schedule rec_radiko.sh rec_radiru.sh schedule_sync.sh
- add_schedule rm_scheduleについては、所有者をrootにして、sビットを立てておいた。
$ sudo chown root:wheel add_schedule rm_schedule $ sudo chmod u+s add_schedule rm_schedule
- ちなみに、コピーすると、所有者のrootとそのsビットは失われてしまうので、注意が必要。(移動なら保持される)
ダウンロードとインストール
- ダウンロードして、解凍したら、好みの場所へ移動する。
- 自分の場合は、~/Library/Scripts/rec_radikoru.app。
- そして、残念ながらroot権限のsビットは解除されてしまうので、自分で設定する必要がある。
$ cd ~/Library/Scripts/radikoru/rec_radikoru.app/Contents/Resources/Scripts $ sudo chown root:wheel add_schedule rm_schedule $ sudo chmod u+s add_schedule rm_schedule
動作環境として、wget, swftools, rtmpdump, ffmpegのインストールが必要。
- Xcodeをインストールして、Preferences... >> Downloads >> Components >> Command Line Tools をインストールしておく。
$ ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" $ brew install wget $ brew install swftools $ brew install rtmpdump $ brew install ffmpeg
- さらに、rtmpdump2.4(バイナリ版)もダウンロードして、インストール。
使い方
- 新規作成を選択して、OKボタンを押す。
- 曜日と時刻と録音の継続時間(分)を指定して、
- 選局する。
- すると、今の予約が追加された!
- 削除または修正は、予約を選択してOKボタンを押す。
- 曜日と時刻と録音の継続時間(分)を指定して、次のステップで選局すれば、修正される。
- 修正せずに削除ボタンを押せば、その予約は削除される。
- 録音される場所は、~/Downloads/radikoru/以下に保存される。
- そのファイルをダブルクリックすれば、iTunesにも取り込まれる。
- 自分の環境ではいい感じで動いてくれて、ちゃんと録音することができた!
しばらく使い込んでみようと思う。