2015/07/28

クエリレコード作成と番組自動予約

手動で予約~録画、視聴の流れはできたかな。次は自動予約だ。
前回はRedmineプラグインだったので制約が結構あったが、今回は自由がある。
その代わり面倒なところもある。

Queryモデル作成

$ rails g  scaffold query title:string start_time:time end_time:time query_type_id:integer category_id:integer sub_category_id:integer priority:integer

Queryレコード作成・変更・削除が必要なのでscaffoldで生成したコードを全部使用する。

query_type_idは、予約:1か検索:2を示す部分で、自動予約処理で使用する。
予約したいわけじゃなく「今日のゴールデンタイムのドラマは?」 を見たいときは検索タイプ。

start_timeとend_timeは、日時ではなく時間帯を指定したいので datetime ではなく time 型である。

priorityは将来的なもので、自動予約で時間がバッティングした場合、どちらを優先するのか?を設定しておくことで勝ち負けも自動でやらせようかという思惑。

models/query.rbの中身
# -*- coding: utf-8 -*-
class Query < ActiveRecord::Base
  belongs_to :category, -> { where cate_type: 0 }
  belongs_to :sub_category, -> { where cate_type: 1 }, class_name: 'Category'
  belongs_to :query_type

  def list
    programs = Program.all
    programs = programs.where(category_id: category_id) if category.present?
    programs = programs.where(category2_id: sub_category_id) if sub_category.present?
    programs = programs.where("title like :name", name: "%#{title}%") if title.present?

    # todo: 時間がセットされていないと判断する賢い方法はないのか?
    unless start_time.strftime("%H%M") + end_time.strftime("%H%M") == '00000000'
      programs = programs.where("TIME(start_at) >= TIME(?) and TIME(start_at) <= TIME(?)",
                                start_time, end_time)
    end
    return programs
  end

end

queryのlistメソッドで条件にマッチした番組リストを返す。条件が設定されているかどうかを値が入っているかどうかで判断したいけど、time型の場合が厄介。

f.time_selectでblankありにして、blank設定のままでも"0:00"になってしまう。nilにならない。

これをどうしたら良いの?という記事はいくつか見つかる。
http://stackoverflow.com/questions/14367705/time-select-blank-field-saves-a-default-time-when-form-is-submitted
とかで語られているソリューションがほとんど。だけど自分の肌に合わなかったので、
start_time.strftime("%H%M") + end_time.strftime("%H%M") == '00000000'
と、若干強引な方法で確認する羽目となった。

検索結果表示は、番組リスト(Program#index)で済ませる
内部処理はこんな↓
    @q = Program.where(video_id: nil).ransack(params[:q])
    if params[:query_id].present?
      @programs = Query.find_by(params[:query_id]).list
    else
      @programs = @q.result
    end
    @programs = @programs.order(:start_at).page(params[:page])
query_id付きで呼ばれた場合と普通の時で処理分け。
ransackも機能する必要があるので、@qの処理はどっちでもやる。

自動予約

繰り返し実行なのでActiveJobではなく、rake task にして whenever で回す。
タスクはすごく単純。 予約タイプのQueryレコードから条件にマッチする番組の予約をするだけ。
# -*- coding: utf-8 -*-
namespace :query do

  desc "自動予約"
  task :reserve => :environment do
    Query.where(query_type_id: 1).each do |q|
      q.list.where(video_id: nil).where('start_at > ?',Time.now+60).each do |program|
        program.reserve
      end
    end
  end

end

予約済みや、今より過去の番組を予約しないように、若干の条件付けを施す程度。
これだけで結構ちゃんと機能している。
以前に書いた処理に比べれば大分シンプルにまとまったような気がする。

これで全体機能は一段落ですが、実際に運用し始めるとまた色々問題が出るんですよねぇ。

2015/07/25

予約レコード周りのその他こまごましたところ

大筋の機能は何とかイメージ通りに実装できたのですが、細かいところでも知らないがゆえに
引っかかりながらの実装となった点をまとめておく。

flashメッセージ表示

予約成功!、予約失敗などを表示するのをflashメッセージを使ってやろうとしたんだけれど、
bootstrapの場合、Rails標準のflashタイプじゃなくてbootstrap後に変換が必要なことを知らず
ちょっとはまってしまった。

通常 redirect_to うにゃうにゃ, notice: "成功!" ってやると思うんだけれど
この"notice"をbootstrap的にはclass "alert alert-success"とかに変換しないと出ない。
その辺りの記事は沢山あった。よくあるパターンとしては
notice => alert-info、alert => alert-warning、error => alert-danger とかに対応させている。
この通りやるのだが、メッセージが表示されない。もしくは無色で表示される。
違う。バックが青とか赤で囲われて欲しいのだよ。結局自分の場合、

notice => alert-success、alert => alert-danger とした。
バックカラーが、successがグリーン、dangerが赤で表示されるから。それ以外は白?だった。
それに、redirect_toのオプションで付けられるのは、notice、alert、flash => {ゴニョゴニョ} の3つ。
noticeとalertだけなら簡単だし。

予約時間の重複チェック

チューナー1つしか無いので、予約しようとした時間帯に引っかかる予約済みレコード確認をして、引っかかっしまう場合はエラーにしたいわけです。
検索としては、既存レコードの開始時間がその時間帯内である もしくは 終了時間がその時間帯内にある場合は・・・です。ということは ”OR” でつなげるわけです。
Program.where(ゴニョゴニョ)で一気に書こうとするとSQL文をなが~く書かないとならない。
嫌なので、"OR"はRubyでやることで全体コードをシンプルにした。

video_on = Program.where.not(video_id: nil)
start_on = video_on.where(start_at: (@program.start_at..@program.end_at))
end_on = video_on.where(end_at: (@program.start_at..@program.end_at))
if start_on.present? or end_on.present?
  redirect_to program_url, alert: 'Reserved violation another videos'
return
end
こんな感じ。BETWEEN生成はwhereに任せ、面倒なORはRubyで。
連続しているものは許したいので実際にはstat,endは微妙に調整する。

録画ジョブ開始タイミングは番組時間の30秒前

ActiveJobのperformが開始されて実際の録画を開始するまでには結構時間がかかる。
なのでJenkins処理でやっていた時のように、少し早めにスタートさせて sleep でタイミング調整
をするようにしている。
recfsusb2n自体にも--waitで待つ機能があるようだけど正確に働かない感じだったので
スクリプト内のsleepで待つようにした。

録画時間は15秒前終了

重なってはいないけど録画時間が連続している場合、多少のギャップを作ってチューナー
を空けておかないと録画開始時にデバイスエラーになってしまうので、若干短めにしておく。


とまあ、細かいところだけれど、録画失敗してがっかりとならないようにやっておかないとね。
で実際の録画ジョブコードは以下のとおり。
  def perform(video)
    if video
      # write path
      path = video.program.start_at.strftime("%Y%m")
      fdir = "#{VIDEO_ROOT}/#{path}"
      Dir.mkdir(fdir, 0777) unless Dir.exist?(fdir)

      fname = "#{path}/#{video.id}"
      fpath = "#{VIDEO_ROOT}/#{fname}"

      # recording
      channel = video.program.channel.ch
      duration = video.program.duration - 15
      wait = (video.program.start_at - Time.now).to_i

      sleep wait if wait>0

      p "------recording start: #{fname}"
      begin
        `tvoff`
        # recode
        `recfsusb2n -b -i hd #{channel} #{duration} #{fpath}.ts`
        `tvon`
        # encode
        p `ffmpeg -loglevel 8 -y -i #{fpath}.ts -c:v libx264 -c:a libfaac -preset superfast -f\
 mp4 -threads 2 -b:v 2000k #{fpath}.mp4`
        # poster
        p `ffmpeg -loglevel 8 -i #{fpath}.mp4 -ss 12 -vframes 1 -an -s 320x180 #{fpath}.jpg`
        # successed
        video.update_attributes(status_id: 2, filename: "#{fname}") # successfully.
        File.delete("#{fpath}.ts")

      rescue => e
        p e.message
        video.update_attributes(status_id: 3) # recording job failed.
      end
      p "------recording end: #{fpath}"
    end
  end

録画の前後でtvon、tvoffっていうのを実行するようになっていますが、これは自分が適当に
作ったシェルスクリプトでして、以下の様なシロモノです。
tvon
#!/bin/bash
recfsusb2n -b -i hd -H 8888 1>/dev/null 2>/dev/null
tvoff
#!/bin/bash
PID=`pidof recfsusb2n`
if test "$PID" != "" ; then
    kill -9 $PID
fi
録画していない時はリアルタイム視聴できる状態にスタンバイさせておこうというものです。
朝方とか結構重宝します。家族食事中は子供にTVを見せないポリシーなもんで。

2015/07/23

録画予約をActiveJob with sidekiq で

前回の記事で、録画ジョブをActiveJobでやる話を書きました。
実際にやってみて、これまた色々ハマったので今回はそのへんの話を書きたいと思います。

要件
1.EPG情報から直近7日間中の何時何分に実行という予約ができること。
その間、システムリブートしても予約が消滅しないこと。キューの永続化。
2.ジョブが失敗してもリトライしない。スケジュールした時間に意味あるので。
4.予約したものを途中キャンセルできること。


RecordJob という ActiveJob::Baseの派生クラスを作成
$ rails g job record
すると、app/jobs/record_job.rbファイルが作られます。最初は以下の様な感じ。
class RecordJob < ActiveJob::Base
  queue_as :default

  def perform(*args)
    # Do something later
  end
end
このperform関数内に録画処理をずらずらと書いておくわけですが、その内容とは関係ない周辺処理でいくつかハマってしまいました。

1.指定日時分に実行するジョブ登録と実行(perform)

登録自体は簡単です。
  # 予約
  def reserve
    start = self.program.start_at - 30
    job = RecordJob.set(wait_until: start).perform_later(self)
    update_attribute(:job_id, job.job_id)
  end
このなメソッドを、ビデオレコードモデル models/video.rb に書いておいて、予約時に video.reserveを呼ぶだけです。
RecordJob.set(wait_until: start).perform_later(self) がActiveJobを通してSidekiqにstartで指定した日時分に実行するスケジュールが登録されます。redisにスケジュールは永続化するので、途中システムリブートしても大丈夫。

なぜ perform_later(video.id)ではなく、perform_later(self) なのか?

ネット記事を読みながらやると、オブジェクト渡しはシリアライズが入ってよろしくないからID渡しをするのだ的な内容が多かったので、最初は自分もそうしてみました。
 ところが!
最初のperform実行ではちゃんとvideo.idが渡って滞り無く処理されるんですが、不思議なことに2回めのジョブ(もちろんIDは異なる)でも最初のIDでperformが実行されてしまいました。
perform(video_id)で受けるのをやめてみて perform(*args) で受けてみても結果同じでした。

↑後で気づいたんだけど、whereではなくfind_byを使ったためと思われる。perform(id)ができないというわけではないようだ。

その後知りましたが、現在のActiveJobのperformへはオブジェクト渡しは悪い方法ではないらしい。シリアライズ負荷がどうのという懸念は解消しているらしい。

ただオブジェクト渡しだと、ジョブ実行前にそのオブジェクトレコードが削除された場合は事前に調べることが出来ず、例外エラーとなってしまう。これはちゃんとレコード削除とジョブキャンセルを同期させれば問題ないか。

2.失敗してもリトライしないように

ネット上は色々なソリューションがあるようです。

ActiveJobオプションをジョブクラスに記述する。
job_options retry: false

Sidekiqオプションを記述する。
sidekiq_options :retry => false

ふむふむ? どっちも効きませんけど・・・
結局自分は、以下のコードを、initializers/sidekiq.rb に加えました。
Sidekiq.configure_server do |config|
   config.server_middleware do |chain|
     chain.add Sidekiq::Middleware::Server::RetryJobs, :max_retries => 0
   end
end

3.SidekiqスケジュールをRailsアプリからキャンセルする

放っておいてもジョブ失敗するだけでシステムが死ぬわけじゃないんで支障はない
のだけれど、ログが汚くなるし、sidekiq_webで見た時にわけわからなくなるんで、
予約キャンセルでジョブスケジュールも削除したいわけです。

探してみましたが、ActiveJobからキュー状態を調べたり削除したりすることが出来ない?

perform_laterが返すJobのjob_idは、SidekiqのjobIDとは異なる? むむぅ。調べた結果、
Sidekiqスケジュールのアーギュメント内にActiveJobのjob_idが記録されている。
ということで以下のコードをレコード削除メッソド内に記述することで解決。
jobs = Sidekiq::ScheduledSet.new.select do |job|
  job.args[0]['job_id'] == @video.job_id
end
jobs.each(&:delete)
冒頭のreserveメソッドでActiveJobのjob_idをレコード上に覚えておくのは、このため。
以上が、やりたいこと以外のやらなくてはならなかったこと。結構苦労した。