2012/08/31

録画チケットリスト項目を縦表示に

この録画システム構築も終盤に近い感じになってきた。ちょっと中継ぎのような内容だけれど、これはこれで自分的には勉強になったと思ってる。

以前の記事でクエリ機能付きの録画チケットリスト表示を作成した。というか既存のソースをちょっと修正する程度で対応していた。デスクトップならさほど気にならないけど、モバイル端末でリスト表示すると、題名やカテゴリの幅が縦書きかい?ってくらい狭くなってしまう。

これでは読みにくいので、Redmineプラグイン作成時に作ったように、ビデオサムネイルの隣にクエリ項目が縦に並ぶようにしたいな。そして、項目順のソートもちゃんと機能するようにしたいな。
と思ったので、やってみた。よくわからないことだらけだけど、何となく出来た。

前回の記事で作った、videoコントローラのshowアクションはそのまま使う。元々クエリやソートを行えるコードが含まれている。
ビューのshow.html.erbもそのまま使用する。

修正ポイントは、その中で下のように部分レンダリングで呼び出している  '_list.html.erb' をどうする?ってところ。
<%= render :partial => 'video/list', :locals => {:issues => @issues, :query => @query} %>

部分的なパッチではどうにもならず、フルスクラッチに近いのでソースを載せます。
_list.html.erb
<%= form_tag({}) do -%>
<%= hidden_field_tag 'back_url', url_for(params), :id => nil %>
<div class="autoscroll">
  <table class="list issues">
    <% previous_group = false %>
    <tbody>
      <% issue_list(issues) do |issue, level| -%>
      <% if @query.grouped? && (group = @query.group_by_column.value(issue)) != previous_group %>
      <% reset_cycle %>
      <tr class="group open">
        <td colspan="<%= query.columns.size + 2 %>">
          <span class="expander" onclick="toggleRowGroup(this);"> </span>
          <%= group.blank? ? 'None' : column_content(@query.group_by_column, issue) %> <span class="count">(<%= @issue_count_by_group[group] %>)</span>
          <%= link_to_function("#{l(:button_collapse_all)}/#{l(:button_expand_all)}", "toggleAllRowGroups(this)", :class => 'toggle-all') %>
        </td>
      </tr>
      <% previous_group = group %>
      <% end %>

      <tr id="issue-<%= issue.id %>" class="hascontextmenu <%= cycle('odd', 'even') %> <%= issue.css_classes %> <%= level > 0 ? "idnt idnt-#{level}" : nil %>">

        <td><% thumb = issue.attachments.detect {|a| a.content_type == "image/jpg"} %>
          <% if !thumb.nil? %>
          <%= link_to image_tag("/videos/"+thumb.disk_filename), {:action => 'play', :id => issue.id } %>
   <% end %>
 </td>
 <!-- 縦表示 -->
        <td><table>
            <% query.columns.each do |column| %>
            <tr>
<!--
              <td align=right title=<%= "並び替え'"+column.caption+"'" %>>
                <%= link_to column.caption, :controller=>"video", :action=>"show", :sort=>"#{column.name},start_date:desc" %&g\
t;
-->
              <%= v_column_header(column) %>
              <td align=left><%= column_content(column, issue) %>
            </tr>
     <% end %>
        </table></td>
      </tr>
      <% end -%>
    </tbody>
  </table>
</div>
<% end -%>

'list issues'テーブルヘッダはサクッとなくした。
グループ表示はどうしようかと思ったけれど、それなりに機能するんで残した。
オリジナルでの項目内容表示は以下の一行で簡単に表示できてしまうけど
<%= raw query.columns.map {|column| "<td class=\"#{column.css_classes}\">#{column_content(column, issue)}</td>"}.join %>
サムネイルの隣に縦に並べたいのでサブテーブルを作って、一つ一つ'tr/td'で表示する

最初にとった方法

テーブルヘッダに表示されているチケット項目名はどうやって表示するんだろ?
オリジナルでは、column_header(column)だけでヘッダに項目が表示されているようだ。きっとヘルパー関数だろう。issuesヘルパーだろうと思ったが、あったのは、redmine/app/helpers/queries_helper.rbの中だった。
なるのど、これで、column.captionに項目名が入っていることが分かった。
column.nameは?と思ったらこっちはレコードのカラム名だったので表示用じゃない。

項目ソートさせるにはどうするんだろ?
色々調べたらlink_toに:sortを付けておくとソートしてくれる感じになった。
<%= link_to column.caption, :controller=>"video", :action=>"show", :sort=>"#{column.name},start_date:desc" %>
だけど、これだとソートはされるが昇順/降順切り替えに対応出来ない。

普通のチケットリストヘッダはどうやってるんだろ?

queries_helperのcolumn_header(column)がその部分のようだ。
ここからsort_helperのsort_header_tagが呼ばれる。
さらに、sort_link関数で上記のようなソートオプション付きのリンクが生成される。
最終的にRailsのcontent_tagを使って'th'タグで出力される。

なるのど、column_header()とsort_header_tag()の2つでやっているのだな。
直接'th'タグを指定しているので残念ながらそのままじゃ利用できない。惜しい。

ヘルパー関数追加
’th’の代わりに'td'タグで出力する関数を用意すれば良さそうだ。
プラグイン作ったときに自動で生成されたvideo_helper.rbに加えることにした。
helpers/video_helper.rb
 -*- coding: utf-8 -*-                                                                                                                    
module VideoHelper
                                              
  def v_column_header(column)
    column.sortable ? v_sort_header_tag(column.name.to_s,
                                        :caption => column.caption,
                                        :default_order => column.default_order) :
      content_tag('td', h(column.caption))
  end
                                              
  def v_sort_header_tag(column, options = {})
    caption = options.delete(:caption) || column.to_s.humanize
    default_order = options.delete(:default_order) || 'asc'
    options[:title] = l(:label_sort_by, "\"#{caption}\"") unless options[:title]
    content_tag('td', sort_link(column, caption, default_order), options)
  end
end
それぞれの関数を、v_column_header、v_sort_header_tagにして'th'を'td'にしただけ。
これがビューで使えるように、コントローラファイル(video_controller.rb)に
helper :video
の1文を追加。ビューのみで使用するので、include VideoHelperは必要ない。
これでテーブルのtd項目で昇順/降順切り替え付きのソートリンクが出来た。

チケットごとの項目内容は、column_content(column, issue) で簡単に表示できるんで言うことなし。

見やすさは、ブラウザや端末別のビューを用意しておくとかで、もっと改善するだろうね。その辺に興味が湧いたらまたいじってみようと思う。

次回の記事はこの録画システム構築の終盤として、ビデオ簡易編集についてです。

2012/08/28

番組予約タスクの重複解決

チューナが1つしかないんで、時間が重なっていると何方かの録画が失敗するわけですね。これをどうにかしたいなぁということで、取り組んでおりましたが・・・

という感じの話と

外部タスクと、Redmineのフック処理でコードを共有化する際に行ったこと

について。

やりたいこと
  1. 絶対録画したいやつを、自動予約や不意の予約が重なっても保護されること。
  2. マニュアル操作での予約で重複があることがすぐに分かるようにすること
1は、チケットのプライオリティ属性を使えばいいかと。重複している番組チケットのプライオリティが高いものが優先されるという処理を何処かで行えば良い。

2は、Redmineのフックスクリプト内で重複を確認して、重複していることをチケットのステータスで知らせるようにすれば良い。

結論から先に言うと、1はまあ出来たかな。2はどうやってもうまく動かない。
もう1歩か2歩先のスキルが必要な感じだ。ということで完全ではないけれど、こうしたいのだ!というところまでは形になったと思うので、そこまでの考えや手順を記録しておく。
(願わくば、どなたか初心者にも分かるようにご教授賜りたい気分です)

思惑
  • 重複確認は、前記事で構築した’VideoTimeline’レコードを使えば簡単にできるはず。
  • 重複が発生する可能性があるのは、自動予約タスクとRedmine操作での予約の時。
  • 重複したチケットのうち負けたチケットは録画が実行されないように。
  • 実行されないけれど、予約されており、無効状態。
  • 無効/有効はJenkinsジョブのアクティブ・非アクティブと連動する。
  • 外部タスクからと、Redmine内部からの処理で重複する部分を共有したい。
共通部分の分離
video-jobs.rb
# -*- coding: utf-8 -*-

# Jenkins接続
Hudson.settings = {:url => 'http://localhost/jenkins', :crumb => false }

def get_job_name id
  "record_#{id}"
end

# ジョブ作成
def create_job info

  jobname = get_job_name info[:issue_id]
  job = Hudson::Job.get jobname
  job.delete if job
  job = Hudson::Job.create jobname

  # 設定
  config = REXML::Document.new(Hudson::Job.get("record_0").config)

  # コマンド
  commands = [
              "/opt/task/ready.rb %d" % info[:issue_id],
              "/opt/task/record2.sh %d %d %s" % [
                                                 info[:channel],
                                                 info[:duration],
                                                 info[:output]
                                                ],
              "/opt/task/record2.rb %d" % info[:issue_id]
             ]
  config.elements.each_with_index('/project/builders/hudson.tasks.Shell') {|shell, i|
    if i<commands.size
      shell.elements["command"].text = commands[i]
    end
  }
  # 時間
  config.elements["/project/triggers/hudson.triggers.TimerTrigger/spec"].text =
    "%s %s %s %s *" % [
                       info[:datetime][10..11],
                       info[:datetime][8..9],
                       info[:datetime][6..7],
                       info[:datetime][4..5]
                      ]
  job.update config.to_s
  job.enable
  return true
end

def entry_job issue, time, channel

  # 1分前にジョブがスタートするように
  datetime = Time.parse( issue.start_date.strftime("%Y%m%d") + format("%04d",time) ) - 60
  duration = issue.estimated_hours.to_i * 60

  # 時間調整
  nowtime = Time.now
  if datetime < nowtime
    if datetime + duration < nowtime
      # 終わってる・・・
      issue.tracker_id = 1
      return
    else
      # 時間が過ぎてる!後半だけでも!
      duration = duration - (nowtime - datetime) - 60
      datetime = nowtime + 60
    end
  end

  info = {
    # チケット番号
    :issue_id => issue.id,
    # 録画開始日時
    :datetime => datetime.strftime("%Y%m%d%H%M"),
    # 録画時間
    :duration => duration,
    # チャンネル
    :channel => channel,
    # 保存場所
    :output => "/opt/videos/%d" % issue.id
  }

  # ジョブ作成
  if create_job info
    # 実行中
    issue.status_id = 2
  end

  # 予約トラッカー
  issue.tracker_id = 2

  # 重複?
  if duplicated? issue
    disable_job issue
  else
    issue.save
  end

  puts "ID: #{issue.id}"

  update_timeline issue
end

def delete_job issue
  job = Hudson::Job.get get_job_name issue.id
  job.delete unless job.nil?

  update_timeline issue
end

def disable_job issue
  job = Hudson::Job.get get_job_name issue.id
  job.disable unless job.nil?
  # 無効
  issue.status_id = 8
  issue.save
end

def enable_job issue
  job = Hudson::Job.get get_job_name issue.id
  job.enable unless job.nil?
  # 実行中に復活
  if issue.status_id != 2
    issue.status_id = 2
    issue.save
  end
end

# タイムライン更新
def update_timeline issue
  vt = VideoTimeline.where(:issue_id =>issue.id).first
  if vt
    vt.reserved = issue.tracker_id == 2
    vt.save
  end
end

def duplicated? issue
  vt = VideoTimeline.where(:issue_id =>issue.id).first
  vts = VideoTimeline.where("reserved = true and start_on <= '%s' and end_on >= '%s'" % [vt.end_on, vt.start_on])
  return vts && vts.size>0
end

def priority vt
#  ActiveRecord::Base.lock_optimistically = false
  vts = VideoTimeline.where("reserved = true and start_on <= '%s' and end_on >= '%s'" % [vt.end_on, vt.start_on])
  if vts
    # ソート
    issues = Issue.find( vts.map(&:issue_id), :order => "priority_id desc, estimated_hours" )
    if issues && issues.size>0
      # 有効
      enable_job issues.shift
      # 無効
      issues.each {|issue| disable_job issue } if issues.size>0
    end
  end
#  ActiveRecord::Base.lock_optimistically = true
# Issue変更フック内で別のIssueに対する操作を行おうとすると
# ActiveRecord::StaleObjectError (Attempted to update a stale object: Issue)
# というエラーが出てしまう。このエラーを出ないようにすることが、上記の設定で可能だが
# Issue.find自体が正常動作しないようなので、結局別のIssueに対する操作ができそうにない。
# 従って、こっち側は外部タスク専用とする。
end
こんな感じで、Jenkinsジョブ周りの処理とか、チケットステータス更新とか、タイムラインから重複を確認する関数とかをひとまとめにした。
ここでのポイントは、処理内容ではなくて外部タスクからとRedmine内部フックからの両方から使えるようにするということ。

requireを使わない。Redmineプラグイン側はGemfileで指定する。
外部タスク側で必要なrequireやロード、初期化を行う。

という感じにすると、共有がしやすいようだ。

Redmineフック video_hooks.rb
# -*- coding: utf-8 -*-
# $: << "/opt/task"
# $: << "."
# require 'video-jobs'
load '/opt/task/video-jobs.rb'

class VideoHooks < Redmine::Hook::ViewListener

  # チケット更新
  def controller_issues_edit_before_save(context)
    edit_hook context[:issue]
  end

  # バルク更新(チケットごとに呼ばれる)
  def controller_issues_bulk_edit_before_save(context)
    edit_hook context[:issue]
  end

  def edit_hook issue
    # 番組内のチケット
    if issue.project_id == 1
      if issue.tracker_id == 1 || issue.status_id == 5
        do_cancel issue
      elsif issue.tracker_id == 2
        time = issue.custom_field_values[0].to_s
        channel = issue.custom_field_values[1].to_s
        entry_job issue, time.to_i, channel.to_i
      end
    end
  end

  # キャンセル
  def do_cancel issue
    # ジョブ削除して、録画キャンセルチケットに
    delete_job issue
    issue.status_id = 5 if issue.status_id > 1
    issue.tracker_id = 1
  end

end
ほとんどの処理を外(video-jobs.rb)に出してしまったので、以前書いたフックスクリプトより格段にシンプルになった。ここでのポイントは、requireで'video-jobs'をロードするんじゃなくて、直接loadでパス指定で読んでいるところでしょうか。

たいした事じゃないけれど、$: << 'パス'ってやってロードパスに追加して、requireするってのをよく見かける。Jenkinsジョブとして動作させる事を考えるとカレント'.'を追加しても意味が無い。フルパスを指定するなら、requireである必要ないし、そもそもロードしたいのはrubyスクリプトなので、直接loadするのが自然だと思う。
無理してrequireを使う例が多い気がするのは、気のせいか?

外部タスク priority.rb
#!/usr/bin/ruby
# -*- coding: utf-8 -*-
require 'rubygems'
require 'active_record'
require 'hudson-remote-api'
load '/opt/task/video-jobs.rb'

# レコード
class Issue < ActiveRecord::Base;end
class VideoTimeline < ActiveRecord::Base; end

ActiveRecord::Base.establish_connection(
                                        :adapter => 'mysql2',
                                        :host => 'localhost',
                                        :username => 'redmine',
                                        :password => 'redmine',
                                        :database => 'redmine'
                                        )

# clean dust
VideoTimeline.where( :reserved => true ).each {|vt|
  issue = Issue.find( vt.issue_id )
  vt.reserved = false unless issue.tracker_id == 2
  vt.save
}

# main
VideoTimeline.where( :reserved => true ).each {|vt| priority vt }
これは、自動予約タスク処理の直後に動かして、重複している予約チケットの優先順位から上位のものだけを生かして、それ以外は無効にするスクリプト。
こちらは思い通りに動作している。

思い通りにいかなかった事
話が戻るけど、Redmineのフックスクリプトで、チケット予約を行った際に重複をチェックして重複していたら、どうのこうのという処理を行いたかった。その辺の処理がvideo-jobs.rb内のduplicated?関数とpriority関数。

priority関数内にもコメントしてあるけど、Redmineのチケット更新処理フック内で別のチケットの更新とかアクセスとかしようとするとロック機構が働いてエラーが出てしまうようなのだ。そのエラーを回避する事はスクリプト内に書いてある通り出来たけれど、チケット検索処理自体が正常に動作しなくなるみたい。
この辺色々やってみたけどどうしても解決しないんで、ちょっとあきらめた。
外部タスクだけで活用する事にした。

仕方ないと思って、今更新中のチケットが重複しているかどうかだけをチェックしてステータス変更するくらいなら出来るだろうと思ったけど、なぜかduplicated?関数が正常動作しない。処理はされているようだけれど、正しい結果が得られない。トレースしてもスルーされてしまう感じ。こっちも原因がつかめていない。

と、不完全な状態ではあるけれど、ハングはしなくなったし、重複確認もリアルタイムは出来てないけどpriority.rbは動作しているので半分くらいは解決したかな。

この辺はかなーり高いスキルとじっくり取り組み時間が必要かも。ちょっと別の事に取り組もうかな。

2012/08/18

DBにタイムラインテーブル構築

この録画システム用にカスタムDBテーブルを作ることにした。
MySQLとかよく知らないんで、作成はRedmine(Rails)のモデル作成スクリプトで。
アクセスはActiveRecordとかRedmineプラグインから。SQL文は書きません。

狙いは
  • かねてから問題だった番組情報の更新がうまく行かず番組チケットが重複してしまう問題の解決手段
  • 処理速度改善
  • 動的スケジュールの下地(将来機能)
日々取得するEPG情報の9割弱が既存番組情報
EPG情報は一週間分の情報が含まれている。新しい番組情報は一週間後の1日のものがほとんど。それ以外は既存の番組情報。厄介なのは一週間の間にプログラムが色々変更されたりすることだ。

今までは番組名や説明文に変更があれば更新する程度の対応はしていた。
それ以外の放送時間とかは変更されない。というか変更に対応できていなかった。
そのため大幅な変更があった場合は新規番組として認識して同じ番組が重複していた。
それが自動予約対象だったりすると、もう大変。手動でお掃除である。

オリンピック番組がよいサンプルになった
番組枠はあっても、内容がめっちゃ変わる。終了時間が1時間変更されたり、ジャンルが変わったり。もう番組の変更とは言えないくらい変わった。
こういうスペシャル番組が特に自分が残したい対象なのでどうにかしたい。

そろそろ本題
今までRedmineのモデルだけで何とかしてきたけれど、時間を扱うカスタムフィールドを作ることが出来ない。仕方なく自前のテーブルで対応することにした。Redmineプラグインで視聴や予約処理をする部分を作っているので専用モデル作っても大変じゃなさそうだし。

タイムラインテーブルを作ることにした
録画時間がRedmine既存モデルで日付と時間に分かれているが、検索に時間がかかるのと、今後のことも考えて日時を扱うシンプルなレコードを用意して、検索時間の短縮とシンプル化を計る。同時に大幅な変更にもある程度耐えられるような検索が行えるように。

ここから本題(前置き長くてすみません)

もちろんRedmineプラグインでモデルを作ったことがなかったんで試行錯誤した。その辺のこともちょっと書いておくよ。巷のハウツー情報はRedmine2.0以前の物が多く、手順が違ったりして最初は結構戸惑ったよ。

テーブル内容
issue_id : チケット番号
start_on : 開始日時
end_on : 終了日時
channel : チャンネル
reserved : 予約?

後からstart_on,end_onとかカッコつけずにstart_time,end_timeとかにすればよかったと後悔することになったけど、作ってしまったのでそのままである。

モデルテンプレート作成
$ sudo RAILS_ENV=production ruby script/rails generate redmine_plugin_model redmine_video timeline issue_id:integer start_on:datetime end_on:datetime channel:string reserved:boolean

すると、以下の様にテンプレートスクリプトファイルが作成される。
/var/lib/gems/1.9.1/gems/activesupport-3.2.6/lib/active_support/dependencies.rb:251:in `block in require': iconv will be deprecated in the future, use String#encode instead.
create plugins/redmine_video/app/models/timeline.rb
create plugins/redmine_video/test/unit/timeline_test.rb
create plugins/redmine_video/db/migrate/001_create_timelines.rb

db/migrate/日付_create_timelines.rb ってなるから001_に直せという記事があるが、最近のRedmineでは001になるらしい。

やり直したかったら
$ sudo RAILS_ENV=production ruby script/rails d redmine_plugin_model redmine_video timeline

で生成したテンプレートを削除出きる。

'd'はdestroyの省略形。

$ ruby script/rails
でどんなコマンドがあるか確認できる。因みにgenerateは'g'。

レコード名が気に入らなかったので
$ sudo RAILS_ENV=production ruby script/rails g redmine_plugin_model redmine_video video_timeline issue_id:integer start_on:datetime end_on:datetime channel:string reserved:boolean

で、以下のファイルが作成された。
/var/lib/gems/1.9.1/gems/activesupport-3.2.6/lib/active_support/dependencies.rb:251:in `block in require': iconv will be deprecated in the future, use String#encode instead.
create plugins/redmine_video/app/models/video_timeline.rb
create plugins/redmine_video/test/unit/video_timeline_test.rb
create plugins/redmine_video/db/migrate/002_create_video_timelines.rb

ん?002_create_video_timelines.rb? なんで002なんだ? どうしてだか、001しかないのに002を消したと言っている。実際001が残ってしまっている。手動で消したよ。
気持ち悪いので、最初からやり直して、001が作られるようにした。

DBマイグレーション
$ sudo rake db:migrate:plugin NAME=redmine_video RAILS_ENV=production

じゃなくて、最近は
$ sudo rake redmine:plugins:migrate NAME=redmine_video RAILS_ENV=production

だそうだ。これで実際にDBにレコードが作成される。

失敗したら
$ sudo rake redmine:plugins:migrate NAME=redmine_video RAILS_ENV=production VERSION=0

ってやればテーブルがドロップされる。

RAILS_ENVとか環境変数は、前につけたり後につけたりしてるけど、rubyなどのバイナリツールの場合は前に、rake等シェルツールの場合は後指定って感じかな?

DBを確認してみる
$ mysql -uredmine -predmine redmine
mysql> show tables;
video_timelinesってのができた。
mysql> show columns from video_timelines;
+----------+--------------+------+-----+---------+----------------+
| Field    | Type         | Null | Key | Default | Extra          |
+----------+--------------+------+-----+---------+----------------+
| id       | int(11)      | NO   | PRI | NULL    | auto_increment |
| issue_id | int(11)      | YES  |     | NULL    |                |
| start_on | datetime     | YES  |     | NULL    |                |
| end_on   | datetime     | YES  |     | NULL    |                |
| channel  | varchar(255) | YES  |     | NULL    |                |
| reserved | tinyint(1)   | YES  |     | NULL    |                |
+----------+--------------+------+-----+---------+----------------+
うん。できているようだ。
こんな風に色々試行錯誤しながら進めた。自然と作ったり消したりの作業が気安い感じになってくる。

下準備
これからタイムラインベースの検索をしたいので既存番組チケットにタイムラインレコードを作成する。

まずはお掃除(clean.rb)
#!/usr/bin/ruby
# -*- coding: utf-8 -*-
require 'rubygems'
require 'active_record'
require 'active_support/core_ext'

ActiveRecord::Base.establish_connection(
                                        :adapter => 'mysql2',
                                        :host => 'localhost',
                                        :username => 'redmine',
                                        :password => 'redmine',
                                        :database => 'redmine'
                                        )

class Issue < ActiveRecord::Base
end
class CustomValues < ActiveRecord::Base
end
class IssueCategories < ActiveRecord::Base
end

#
# ダブった番組チケットができてしまうので、トラバースしてダブリを削除する
#
del_issues = []
issues = Issue.find(:all, :conditions => {:tracker_id => 1,:status_id => 1})
issues.each {|issue|
  time = CustomValues.first( :conditions=> {
                               :customized_type => "Issue",
                               :customized_id => issue.id,
                               :custom_field_id => 1 }).value
  # チャンネル
  ch = CustomValues.first( :conditions => {
                             :customized_type => "Issue",
                             :customized_id => issue.id,
                             :custom_field_id => 2 }).value

  same_date_issues = Issue.find(:all, :conditions =>{
                                  :tracker_id => 1,
                                  :status_id => 1,
                                  :start_date => issue.start_date,
#                                  :estimated_hours => issue.estimated_hours,
#                                  :category_id => issue.category_id})
                                })

  next if same_date_issues.size <= 1
#  puts '%d %d' % [issue.id, same_date_issues.size]

  same_date_issues.each {|same|
    if same.id != issue.id
      same_time = CustomValues.first( :conditions=> {
                                        :customized_type => "Issue",
                                        :customized_id => same.id,
                                        :custom_field_id => 1 }).value
      same_ch = CustomValues.first( :conditions => {
                                      :customized_type => "Issue",
                                      :customized_id => same.id,
                                      :custom_field_id => 2 }).value
      if same_time == time and same_ch == ch and same.id > issue.id
        p 'find same ticket %d => %d' % [issue.id, same.id]
        del_issues <<= same.id
      end
    end
  }
}

puts 'deleted %d issues' % del_issues.size
Issue.delete(del_issues)

values = (del_issues.collect {|i| CustomValues.find(:all,:conditions=>{ :customized_id=>i })}).flatten

puts 'deleted %d custom values' % values.size
CustomValues.delete(values)

cate = IssueCategories.select {|c| not Issue.exists?(:category_id => c.id) }
puts 'deleted %d independency categories' % cate.size
IssueCategories.delete(cate)

今までの重複したチケットをできる限り認識して削除しておく。総当りに近いのでものすごい時間がかかった。

タイムライン作成(timeline.rb)
#!/usr/bin/ruby
# -*- coding: utf-8 -*-

require 'active_record'

ActiveRecord::Base.establish_connection(
                                        :adapter => 'mysql2',
                                        :host => 'localhost',
                                        :username => 'redmine',
                                        :password => 'redmine',
                                        :database => 'redmine'
                                        )
class Issue < ActiveRecord::Base; end
class CustomValues < ActiveRecord::Base; end

# 独自レコード
class VideoTimelines < ActiveRecord::Base
  # :issue_id : integer
  # :start_on : datetime
  # :end_on   : datetime
  # :channel  : string
  # :reserved : boolean
end

#
# Timeline登録
#
Issue.find(:all, :conditions=> ["status_id<3"]).each {|issue|
  unless VideoTimelines.exists?(:issue_id => issue.id)

    channel = CustomValues.first( :conditions => {
                                    :customized_id => issue.id,
                                    :custom_field_id => 2}).value
    time = CustomValues.first( :conditions => {
                                 :customized_id => issue.id,
                                 :custom_field_id => 1}).value.to_i

    start_on = Time.parse( issue.start_date.strftime("%Y%m%d") + format("%04d",time) )
    end_on = start_on + issue.estimated_hours.to_i * 60

    timeline = VideoTimelines.create( :issue_id => issue.id,
                                      :start_on => start_on,
                                      :end_on => end_on,
                                      :channel => channel,
                                      :reserved => issue.tracker_id == 2
                                      )

    puts "%d %d %s %s %s %s" % [timeline.id,
                                timeline.issue_id,
                                timeline.start_on,
                                timeline.end_on,
                                timeline.channel,
                                timeline.reserved.to_s]
  end
}

既存番組チケットに紐付いたタイムラインレコードを作成する。
一度実行するだけの単発スクリプトだけど、実際には試行錯誤する上で何度も実行することになるんで、落ち着くまでは大活躍である。

間違ったりうまく行かなかったときは、SQLコマンドで直接削除してから再実行したりする。
なんだかんだと色々やって毎日のタスクが出来上がった。(まだ発展途上だけど)

修正版 entry2.rb
#!/usr/bin/ruby
# -*- coding: utf-8 -*-
require 'rubygems'
require 'active_record'
require 'rexml/document'

ActiveRecord::Base.establish_connection(
                                        :adapter => 'mysql2',
                                        :host => 'localhost',
                                        :username => 'redmine',
                                        :password => 'redmine',
                                        :database => 'redmine'
                                        )
class Issue < ActiveRecord::Base; end
class IssueCategorie < ActiveRecord::Base; end
class CustomValue < ActiveRecord::Base; end
# VideoTimelines 独自レコード
class VideoTimeline < ActiveRecord::Base; end

$entry_count = 0
$update_count = 0
$update_time_count = 0

def create_job id
  # not yet
end

def delete_job id
  # not yet
end

def delete_issue id
  issue = Issue.find(id)

  # 予約実行中?
  delete_job id if issue.tracker_id == 2 and issue.status_id == 2

  CustomValue.delete_all(:customized_id => id)
  VideoTimeline.delete_all(:issue_id => id)

  Issue.delete(id)
  puts "deleted #{id}"
end

def create_issue info

  # 日にちと時間に分離
  start_date = info[:start_time].to_date
  start_time = info[:start_time].strftime("%H%M").to_i

  # チケット作成
  issue = Issue.create( :project_id => 1, #'番組プログラム'
                        :tracker_id => 1, #'番組'
                        :lft => 1,
                        :rgt => 2,
                        :status_id => 1,
                        :priority_id => 2,
                        :done_ratio => 0,
                        :author_id => 1,
                        :subject => info[:title],
                        :description => info[:desc],
                        :start_date => start_date,
                        :due_date => start_date,
                        :estimated_hours => info[:duration],
                        :category_id => info[:category_id] )

  # root_id 更新
  # NULLのままだとRedmineからのチケット更新でクラッシュする
  # 親がない場合は自分のIDを入れるらしい。自分? 更新しか無い?
  issue.root_id = issue.id

  if issue.save
    # 開始時間
    CustomValue.create( :customized_type => "Issue",
                        :customized_id => issue.id,
                        :custom_field_id => 1, #'開始時間'
                        :value => start_time )
    # チャンネル
    CustomValue.create( :customized_type => "Issue",
                        :customized_id => issue.id,
                        :custom_field_id => 2, #'チャンネル'
                        :value => info[:channel] )
    # 内容
    CustomValue.create( :customized_type => "Issue",
                        :customized_id => issue.id,
                        :custom_field_id => 4, #'内容'
                        :value => info[:desc] )
    # descriptionをフィルタできないから。出来るフィールドを作った
  end

  # タイムライン作成
  VideoTimeline.create( :issue_id => issue.id,
                        :start_on => info[:start_time],
                        :end_on => info[:end_time],
                        :channel => info[:channel],
                        :reserved => false )

  $entry_count = $entry_count + 1
  puts "NEW %s" % issue.id
end

def update_issue timeline, info

  issue = Issue.find(timeline.issue_id)

  update_f = false
  update_time_f = false

  # 題名
  if issue.subject != info[:title]
    issue.subject = info[:title]
    update_f = true
  end
  # 内容
  if issue.description != info[:desc]
    issue.description = info[:desc]
    content = CustomValue.first(:conditions => {
                                  :customized_type => "Issue",
                                  :customized_id => issue.id,
                                  :custom_field_id => 4 })
    if content
      content.value = info[:desc]
      content.save
    end
    update_f = true
  end
  # カテゴリ
  if issue.category_id != info[:category_id]
    issue.category_id = info[:category_id]
    update_f = true
  end

  # 時間
  if issue.estimated_hours != info[:duration]
    issue.estimated_hours = info[:duration]
    update_f = update_time_f = true
  end

  # タイムライン
  if timeline.start_on != info[:start_time]
    timeline.start_on = info[:start_time]
    update_f = update_time_f = true

    # 録画時間
    start_time = timeline.start_on.strftime("%H%M").to_i
    time = CustomValue.first( :conditions=> {
                                :customized_type => "Issue",
                                :customized_id => issue.id,
                                :custom_field_id => 1 })
    if time
      time.value = start_time
      time.save
    end
  end

  if timeline.end_on != info[:end_time]
    timeline.end_on = info[:end_time]
    update_f = update_time_f = true
  end

  # 更新
  if update_f
    issue.save

    # 更新 timeline
    if update_time_f
      timeline.save

      # 予約実行中?
      if issue.tracker_id == 2 and issue.status_id == 2
        delete_job issue.id
        create_job issue.id
      end

      $update_time_count = $update_time_count + 1
      puts "UPDATE TIME %d" % issue.id
    end

    $update_count = $update_count + 1
    puts "UPDATE %d" % issue.id
  end
end

#
# チケット登録
#
def entry_issue info

  # マッチするチケットを検索

  # 5分前後の変動を許容する
  range = 5 * 60
  startL = info[:start_time] - range
  startR = info[:start_time] + range
  endL = info[:end_time] - range
  endR = info[:end_time] + range

  timelines = VideoTimeline.where(:channel => info[:channel],
                                  :start_on => startL..startR,
                                  :end_on => endL..endR)
  # 大幅な終了時間変更?
  timelines = VideoTimeline.where(:channel => info[:channel],
                                  :start_on => info[:start_time]) unless timelines.size==0

  if timelines.size>=1
    if timelines.size==1
      #特定された
      update_issue timelines.first, info
      return
    else
      #複数あった!もうなんだかわからないんで全消し
      timelines.each {|vt| delete_issue vt.issue_id }
    end
  end

  # 大幅な変更があると見つけるのが困難なので
  # 何がどう変わったかをちゃんと判別するのはやめて、昔のは消して新規にする

  # かぶっている番組があれば、削除する(消滅した?)
  VideoTimeline.where("channel = ? and start_on >= ? and end_on <= ?",
                      info[:channel], info[:start_time], info[:end_time]).each {|vt|
    delete_issue vt.issue_id
  }

  # 新規に作る
  create_issue info

end # entry_issue

#
# TVプログラム登録
#
xml_file = ARGV.shift + '.xml'
xml = REXML::Document.new open(xml_file).read

ch_name = xml.elements['tv/channel/display-name'].text

xml.elements.each('/tv/programme') {|program|

  start = Time.parse( program.attributes['start'] )
  stop = Time.parse( program.attributes['stop'] )
  duration = (stop - start) / 60

  # 5分以下の短い番組は登録しない :todo: DBに設定できると良い
  next if duration <= 5

  # カテゴリ
  category = program.elements.to_a('category').collect{|c| c.text}.join(".")
  cate = IssueCategorie.where(:project_id => 1, :name => category).first
  cate = IssueCategorie.create( :project_id => 1, :name => category ) if cate.nil?

  info = {
    :duration => duration,
    :start_time => start,
    :end_time => stop,
    :channel => program.attributes['channel']+ '.'+ch_name,
    :title => program.elements['title'].text,
    :desc => program.elements['desc'].text,
    :category_id => cate.id
  }

  info[:desc] = "" if info[:desc].nil?
  info[:title].gsub!(/(【二】|【デ】|【S】|【字】|【SS】|【手】|【解】)/,"")

  entry_issue info
}

puts "%d entried" % $entry_count
puts "%d updated" % $update_count
puts "%d time updated" % $update_time_count
epgdumpの情報を読んで番組チケット更新&登録処理を行う部分。以前はXMLをHashに変換していたけど、今回はREXMLを使ってみた。Rails3の新しい書き方とかActiveRecordの新しく知った書き方とかもあるんで、そんなのを意識的に使って書いてみた。

ここで気づいたんだけれど、
class VideoTimeline < ActiveRecord::Base; end
とか、テーブル名を単数形にしている。実際のテーブル名は'video_timelines'で複数形。おや?と思ってどっちも書いてみたけど単数形でも複数形でもどっちでも大丈夫だった。
Redmineのテーブルやカスタムフィールドテーブルも同じ。なのでいままで複数形で書いていた部分もわざと単数形にしてみた。ふむ。普通に動く。不思議だ。

新たに知った範囲検索
深夜番組とか時間が微妙にずれたりする場合があるんで、時間を範囲で検索したい。どうするんだ?と思ったら
:start_on => L..Rって簡単に書けた!素晴らしいです。

複雑な場合分け検索はやめてありがちな変更だけに絞る。それ以外は諦めて、古いやつを消して新しく作る。そんな考えで作って処理を簡素化した。実際これで結構いけているようだ。

Jenkinsとの連携はまだ
既に予約実行中の番組に時間変更があった場合、Jenkins上のジョブも更新する必要があるだろ。そう思って関数は用意したけど、今のところその必要性が無いのと、今後の対応で思うところがあって空白のままだ。将来の動的スケジュール機能とセットで考えようと思ってる。

自動予約も更新(query2.rb)
#!/usr/bin/ruby
# -*- coding: utf-8 -*-

require 'active_record'
require 'rest_client'
require 'hudson-remote-api'

key = '???'
api = "http://localhost/projects/%s/issues.xml?key="+key+"&query_id="

# レコード
class Issue < ActiveRecord::Base; end
class CustomValues < ActiveRecord::Base; end
class Query < ActiveRecord::Base; end
class Project < ActiveRecord::Base; end
# VideoTimelines
class VideoTimelines < ActiveRecord::Base; end

# DB接続
ActiveRecord::Base.establish_connection(
        :adapter => 'mysql2',
        :host => 'localhost',
        :username => 'redmine',
        :password => 'redmine',
        :database => 'redmine'
)

# project識別名を合成
api = api % Project.first(1)[0].identifier.to_s

# Jenkins接続
Hudson.settings = {:url => 'http://localhost/jenkins', :crumb => false }

# ジョブ作成
def create_job info

  # Config
  return false if !Hudson::Job.list.include?("record_0")
  config = REXML::Document.new(Hudson::Job.get("record_0").config)

  # コマンド定義
  commands = [
              "/opt/task/ready.rb %d" % info[:issue_id],
              "/opt/task/record2.sh %d %d %s" % [
                                                 info[:channel],
                                                 info[:duration],
                                                 info[:output]
                                                ],
              "/opt/task/record2.rb %d" % info[:issue_id]
             ]

  # 時間設定
  config.elements["/project/triggers/hudson.triggers.TimerTrigger/spec"].text =
    "%s %s %s %s *" % [
                       info[:datetime][10..11],
                       info[:datetime][8..9],
                       info[:datetime][6..7],
                       info[:datetime][4..5]
                      ]
  # コマンド登録
  config.elements.each_with_index('/project/builders/hudson.tasks.Shell') {|shell, i|
    if i<commands.size
      shell.elements["command"].text = commands[i]
    end
  }

  # 作成
  job_name = "record_%d" % info[:issue_id]
  job = Hudson::Job.get job_name
  job = Hudson::Job.create job_name if job.nil?

  # 設定
  job.update config.to_s
  job.enable
  return true
end

# 予約クエリー検索
reserve = []
queries = Query.find :all, :conditions => { :project_id =>1, :is_public =>1 }
queries.each {|query|

  # REST-APIでクエリー実行
  doc = REXML::Document.new RestClient.get api+query.id.to_s

  # REXMLでトラバース
  doc.elements.each('issues/issue') {|issue|
    if issue.elements['tracker'].attributes['id'].to_i == 1 &&
        issue.elements['status'].attributes['id'].to_i == 1
      # 予約チケットID収集
      reserve << issue.elements['id'].text.to_i
    end
  }
}

Issue.find(reserve).each {|issue|
  channel = CustomValues.first( :conditions => {
                                  :customized_id => issue.id,
                                  :custom_field_id => 2}).value.to_i
  time = CustomValues.first( :conditions => {
                               :customized_id => issue.id,
                               :custom_field_id => 1}).value.to_i

  # 1分前にジョブがスタートするように
  datetime = Time.parse( issue.start_date.strftime("%Y%m%d") + format("%04d",time) ) - 60

  info = {
    # チケット番号
    :issue_id => issue.id,
    # 録画開始日時
    :datetime => datetime.strftime("%Y%m%d%H%M"),
    # 録画時間
    :duration => issue.estimated_hours.to_i * 60,
    # チャンネル
    :channel => channel,
    # 保存場所
    :output => "/opt/videos/%d" % issue.id
  }

  # ジョブ作成
  if create_job info
    # 実行中
    issue.status_id = 2
  end

  # 予約トラッカー
  issue.tracker_id = 2
  issue.save

  # タイムライン更新
  vt = VideoTimelines.where(:issue_id =>issue.id).first
  if vt
    vt.reserved = true
    vt.save
  end

  puts "ID: #{issue.id}"
}
ただし録画開始時間の取り方はTimelineからではなく従来通り。この辺は色々思案中だけど、タイムライン情報は検索と今後のスケジューリング用だと切り分けて考えているんで、一気に変更し過ぎないように進めている。

redmineプラグインフックも更新(video_hooks.rb)
# -*- coding: utf-8 -*-

# 接続設定
Hudson.settings = {:url => 'http://localhost/jenkins', :crumb => false }

# ジョブ作成
def create_job info

  # Config
  return false if !Hudson::Job.list.include?("record_0")
  config = REXML::Document.new(Hudson::Job.get("record_0").config)

  # コマンド定義
  commands = [
              "/opt/task/ready.rb %d" % info[:issue_id],
              "/opt/task/record2.sh %d %d %s" % [
                                                 info[:channel],
                                                 info[:duration],
                                                 info[:output]
                                                ],
              "/opt/task/record2.rb %d" % info[:issue_id]
             ]

  # 時間設定
  config.elements["/project/triggers/hudson.triggers.TimerTrigger/spec"].text =
    "%s %s %s %s *" % [
                       info[:datetime][10..11],
                       info[:datetime][8..9],
                       info[:datetime][6..7],
                       info[:datetime][4..5]
                      ]
  # コマンド登録
  config.elements.each_with_index('/project/builders/hudson.tasks.Shell') {|shell, i|
    if i<commands.size
      shell.elements["command"].text = commands[i]
    end
  }

  # 作成
  job_name = "record_%d" % info[:issue_id]
  job = Hudson::Job.get job_name
  job = Hudson::Job.create job_name if job.nil?

  # 設定
  job.update config.to_s
  job.enable
  return true
end

# ジョブ削除
def delete_job id

  job = Hudson::Job.get "record_#{id}"
  if !job.nil?
    job.delete
    return true
  end
  return false
end

# タイムライン更新
def update_timeline issue
  vt = VideoTimeline.first(:conditions => {:issue_id => issue.id})
  if vt
    vt.reserved = issue.tracker_id == 2
    vt.save
  end
  #多分VideoTimelineクラス内にメソッドとして作るのが正しいと思うが・・・
end

class VideoHooks < Redmine::Hook::ViewListener

  # チケット更新
  def controller_issues_edit_before_save(context)
    edit_hook context[:issue]
  end

  # バルク更新(チケットごとに呼ばれる)
  def controller_issues_bulk_edit_before_save(context)
    edit_hook context[:issue]
  end

  def edit_hook issue
    # 番組内のチケット
    if issue.project_id == 1
      do_cancel issue if issue.tracker_id == 1 || issue.status_id == 5
      do_reserve issue if issue.tracker_id == 2
    end
  end

  # キャンセル
  def do_cancel issue
    # ジョブ削除して、録画キャンセルチケットに
    if delete_job issue.id
      issue.status_id = 5
      issue.tracker_id = 1
      update_timeline issue
    end
  end

  # 予約?
  def do_reserve issue

    time = issue.custom_field_values[0].to_s
    channel = issue.custom_field_values[1].to_s

    # 1分前にジョブがスタートするように
    datetime = Time.parse( issue.start_date.strftime("%Y%m%d") + format("%04d",time) ) + 60
    duration = issue.estimated_hours.to_i * 60

    # 時間調整
    nowtime = Time.now
    if datetime < nowtime
      if datetime + duration < nowtime
        # 終わってる・・・
        issue.tracker_id = 1
        return
      else
        # 時間が過ぎてる!後半だけでも!
        duration = duration - (nowtime - datetime) - 60
        datetime = nowtime + 60
      end
    end

    info = {
      # チケット番号
      :issue_id => issue.id,
      # 録画開始日時
      :datetime => datetime.strftime("%Y%m%d%H%M"),
      # 録画時間
      :duration => duration,
      # チャンネル
      :channel => channel.to_i,
      # 保存場所
      :output => "/opt/videos/%d" % issue.id
    }

    # ジョブ登録してチケットを実行中に更新
    if create_job info
      issue.status_id = 2
      update_timeline issue
    end
  end
end
こっちもタイムラインに関しては、予約情報(reserved)の更新だけ。この情報はまだ未使用。今後のお楽しみかな。

これで一応番組チケット更新処理と、今後のスケジューリングのための用意が整ったかな?

2012/08/11

録画リストビューにクエリー機能をつけてみた

チケットリストにビデオサムネイルが付く感じ?

前記事で録画チケットから視聴するためのリストビューをredmineプラグインで作ったけどオリンピックとかひたすら録画したら結構な数になった。そろそろ録画ビデオの検索機能が欲しくなった。
ということで、チケットをクエリ検索するのと同じ感覚の録画リストを作ってみた。

プロジェクトメニュー
今までのControllerやViewはそのままに、今回作ったプラグインビューはプロジェクトメニューで表示されるようにした。いろんなアプローチ方法があるって言うのが便利かなと思って。

init.rb
 # Project Menu
  menu :project_menu, :videos_show, { :controller => 'video', :action => 'show' },
  :caption => "Videos", :param => :project_id, :after => :overview
  permission :videos_show, {:video => :show}, :public => true

  Redmine::MenuManager.map :project_menu do |menu|
    menu.delete :activity
  end
こんな感じで、init.rbにプロジェクトメニューで、videoコントローラの'show'アクションが表示されるように。
そしてパーミッションは特に指定する必要がないんでパブリックです。
ついでに、活動(activity)メニューが邪魔なので消しました。
そこにVideosメニューが代わりに入る感じ。

活動メニューは仕事では結構便利だけど、この録画システムではすごい数のチケットが毎日登録されていて、その情報が表示されるとすごい時間がかかる。間違って押してしまうと暫く動かない。目的にそぐわないんで消してしまった。

改良版コントローラ
app/controllers/video_controller.rb
# -*- coding: utf-8 -*-                                                                                                           
class VideoController < ApplicationController
  menu_item :videos_show, :only => [:show]
  menu_item :videos_index, :only => [:index]
  menu_item :videos_view, :only => [:list]
  unloadable

  helper :queries
  include QueriesHelper
  helper :repositories
  include RepositoriesHelper
  helper :sort
  include SortHelper
  include IssuesHelper

  def index
    @issues = get_video_issues
  end

  def list
    # @issues = get_video_issues                                                                                                  
    @issue_pages, @issues = get_video_issues_paginate
  end

  def play
    @issue = Issue.find(params[:id])
  end

  def podcast
    @issues = get_public_issues
    render :layout => false, :content_type => 'text/xml'
  end

  def get_public_issues
    Issue.find(:all, :conditions => {:tracker_id => 3, :status_id => 6}, :order => "start_date DESC")
  end

  def get_video_issues
    Issue.find(:all, :conditions => {:tracker_id => 3, :status_id => 4}, :order => "start_date DESC")
  end

  def get_video_issues_paginate
    paginate(:issue, :per_page => 5,
             :conditions => {:tracker_id => 3, :status_id => 4}, :order => "start_date DESC")
  end

  def show
    @project = Project.find(params[:project_id])
    retrieve_query
    sort_init(@query.sort_criteria.empty? ? [['id', 'desc']] : @query.sort_criteria)
    sort_update(@query.sortable_columns)

    if @query.valid?
      @limit = per_page_option
      @issue_count = @query.issue_count
      @issue_pages = Paginator.new self, @issue_count, @limit, params['page']
      @offset ||= @issue_pages.current.offset
      @issues = @query.issues(:include => [:assigned_to, :tracker, :priority, :category, :fixed_version],
                              :order => sort_clause,
                              :offset => @offset,
                              :limit => @limit)
      @issue_count_by_group = @query.issue_count_by_group
    end

    #render :template => 'issues/index', :layout => !request.xhr?                                                                 
  end
end

前記事から書き加えたのは以下のような感じ。
  menu_item :videos_show, :only => [:show]
  menu_item :videos_index, :only => [:index]
  menu_item :videos_view, :only => [:list]
このmenu_item云々を付けておかないとメニュー押したときに選択状態にならないんだね。あとは、
  helper :queries
  include QueriesHelper
  helper :repositories
  include RepositoriesHelper
  helper :sort
  include SortHelper
  include IssuesHelper
と
  def show
の部分。

この辺りは、redmine/app/controllers/issues_controller.rbのindexアクションを参考に必要な部分だけにした感じ。
クエリ機能とついでにページ機能も付いた。

app/views/show.html.erb
app/views/_list.html.erb
ここは、
redmine/app/views/issues/index.html.erb
redmine/app/views/issues/_list.html.erb
をコピペして多少のパッチを当てただけなのでソースは載せません。
パッチした部分は以下の通り。

index.html.erb
<%= form_tag({ :controller => 'issues', :action => 'index', :project_id => @project },
を
<%= form_tag({ :controller => 'video', :action => 'show', :project_id => @project },
と
<%= render :partial => 'issues/list', :locals => {:issues => @issues, :query => @query} %>
を
<%= render :partial => 'video/list', :locals => {:issues => @issues, :query => @query} %>
あとはcsvとか別フォーマットで出力する場合のところや、atomのところとか、call_hookのところとかを削除して終わり。
最初はsidebarの追加コードを外してたんだけれど、付けてみたらすごい便利になった。
普通のチケットとして扱えて、録画ファイルが添付されているチケットはサムネイルが表示されて、クリックするとビデオ視聴できる。

_list.html.erb
テーブルヘッダのチェックボックスのところをコメントアウトして、テーブル本体を
<!--<td class="checkbox hide-when-print"><%= check_box_tag("ids[]", issue.id, false, :id => nil) %></td>-->
<td><% thumb = issue.attachments.detect {|a| a.content_type == "image/jpg"} %>
<% if !thumb.nil? %>
  <%= link_to image_tag("/videos/"+thumb.disk_filename), {:action => 'play', :id => issue.id } %>
<% end %></td>
こんな感じにリプレース。
image_tagでサムネイル表示して、playアクションへのリンクにした。

なんか、初めからこうすれば良かったってくらい馴染んだ感じになった。
検索できるし、ソートできるし、ページもあるし。

最初はよくわからないまま始めたんで要領が悪かったけど、少しずつ分かってくると、こうすれば楽じゃんっていう発想が出来るようになってくるもんだよね。

Jenkinsジョブ開始からの時間調整

Redmineのプラグイン対応でチケット操作で即時予約実行ができるようになったので
Jenkins側で回すバックエンドスクリプトを頻繁に回す必要がなくなった。

この辺のタスク整理をしていたら、さほど注視してなかった録画が開始されるまでのタイムラグが気になってきた。この辺の対応をやっておくと何かと便利そうなのと、簡単に対応出来そうなので、スクリプト整理と同時にやってしまおう。今回はそこら辺の話です。

reserve2.rb廃止
以前の記事で作ったジョブ登録スクリプトを一時間ごとに実行していたけど、不要になった。

クエリー予約へ統合
自動予約はクエリーからの予約だけで良くなったので、以前の記事で作ったquery.rbにジョブ登録までの機能を統合した。

録画ジョブは録画1分前に起動
Jenkinsジョブが実際の起動する時間を観測すると指定時間より28秒程度遅く起動しているようだ。どうしてこんなにずれるの?という深いところは置いておいて、
起動が思った以上に指定時間より遅いので、1分前に起動して実際の録画時間になるまでウエイトするような処理を加えることにした。
その時間を使って録画開始までの前処理をちょっと入れたり、どうせなら便利に使おう。ざっくり30秒くらいは別の用途に使えるはずだ。

Jenkinsの待機時間設定
Jenkinsでジョブごとに秒指定は出来ないと書いたが、ジョブ起動から何秒待つかという待機時間を秒設定することが出きる。が、ジョブ起動からの相対値なので今回のような用途には向かない。さらに、これをジョブに設定していない場合、Jenkinsのシステム設定の待機時間が使われてしまうので、システムの待機時間を0にしておくか、録画ジョブのテンプレートに0を設定しておく。念のため両方共0設定。

時刻同期設定
以前の記事に追記したntpの設定もしっかりやっておく。手持ちの電波時計と比べてもずれは無いみたいだ。

時間待ちをする ready.rb
#!/usr/bin/ruby
# -*- coding: utf-8 -*-
#
require 'active_record'

ActiveRecord::Base.establish_connection(
        :adapter => 'mysql2',
        :host => 'localhost',
        :username => 'redmine',
        :password => 'redmine',
        :database => 'redmine'
)

class Issue < ActiveRecord::Base
end
class CustomValues < ActiveRecord::Base
end

abort "no arguments" if ARGV.size==0
issue = Issue.find(ARGV[0])
abort "no ticket" if issue.nil?

# 録画トラッカー
issue.tracker_id = 3
issue.save

time = CustomValues.first( :conditions => {:customized_id => issue.id,
                             :custom_field_id => 1}).value.to_s
start_time = Time.parse issue.start_date.strftime("%Y%m%d") + format("%04d",time)

wait_time = (start_time - Time.now) - 4 # 4秒前に録画開始(外から設定できるといいかな)

# 開始待ち
if wait_time > 1
  p 'waiting...%d secs' % wait_time
  sleep( wait_time )
end
ウエイトして時間調整する前処理で、チケットのトラッカーを予約から録画にする程度のことをやっている。いずれ似たような事前処理が増えていくかも。
このready.rbが終わって次のrecrd2.shで録画が開始されるまでの時間が4秒くらいかかる。そのために4秒前に終わるように調整している。

録画ジョブの実行シェルは3つになった
ready.rb ジョブ起動した時に最初に動く
record2.sh 録画を行う
record2.rb チケットへファイルのアタッチをする
3つのシェルコマンドが登録できるように'record_0'ジョブテンプレートも直す。

対応したクエリー予約スクリプト query2.rb
#!/usr/bin/ruby
# -*- coding: utf-8 -*-

require 'active_record'
require 'rest_client'
require 'hudson-remote-api'

key = 'api-key'
api = "http://localhost/projects/%s/issues.xml?key="+key+"&query_id="

# レコード
class Issue < ActiveRecord::Base
end
class CustomValues < ActiveRecord::Base
end
class Query < ActiveRecord::Base
end
class Project < ActiveRecord::Base
end

# DB接続
ActiveRecord::Base.establish_connection(
        :adapter => 'mysql2',
        :host => 'localhost',
        :username => 'redmine',
        :password => 'redmine',
        :database => 'redmine'
)

# project識別名を合成
api = api % Project.first(1)[0].identifier.to_s

# Jenkins接続
Hudson.settings = {:url => 'http://localhost/jenkins', :crumb => false }

# ジョブ作成
def create_job info

  # Config
  return false if !Hudson::Job.list.include?("record_0")
  config = REXML::Document.new(Hudson::Job.get("record_0").config)

  # コマンド定義
  commands = [
              "/opt/task/ready.rb %d" % info[:issue_id],
              "/opt/task/record2.sh %d %d %s" % [
                                                 info[:channel],
                                                 info[:duration],
                                                 info[:output]
                                                ],
              "/opt/task/record2.rb %d" % info[:issue_id]
             ]

  # 時間設定
  config.elements["/project/triggers/hudson.triggers.TimerTrigger/spec"].text =
    "%s %s %s %s *" % [
                       info[:datetime][10..11],
                       info[:datetime][8..9],

                       info[:datetime][6..7],
                       info[:datetime][4..5]
                      ]
  # コマンド登録
  config.elements.each_with_index('/project/builders/hudson.tasks.Shell') {|shell, i|
    if i<commands.size
      shell.elements["command"].text = commands[i]
    end
  }

  # 作成
  job_name = "record_%d" % info[:issue_id]
  job = Hudson::Job.get job_name
  job = Hudson::Job.create job_name if job.nil?

  # 設定
  job.update config.to_s
  job.enable
  return true
end

# 予約クエリー検索
reserve = []
queries = Query.find :all, :conditions => { :project_id =>1, :is_public =>1 }
queries.each {|query|

  # REST-APIでクエリー実行
  doc = REXML::Document.new RestClient.get api+query.id.to_s

  # REXMLでトラバース
  doc.elements.each('issues/issue') {|issue|
    if issue.elements['tracker'].attributes['id'].to_i == 1 &&
        issue.elements['status'].attributes['id'].to_i == 1
      # 予約チケットID収集
      reserve << issue.elements['id'].text.to_i
    end
  }
}

Issue.find(reserve).each {|issue|
  channel = CustomValues.first( :conditions => {
                                  :customized_id => issue.id,
                                  :custom_field_id => 2}).value.to_i
  time = CustomValues.first( :conditions => {
                               :customized_id => issue.id,
                               :custom_field_id => 1}).value.to_i

  # 1分前にジョブがスタートするように
  datetime = Time.parse( issue.start_date.strftime("%Y%m%d") + format("%04d",time) ) - 60

  info = {
    # チケット番号
    :issue_id => issue.id,
    # 録画開始日時
    :datetime => datetime.strftime("%Y%m%d%H%M"),
    # 録画時間
    :duration => issue.estimated_hours.to_i * 60,
    # チャンネル
    :channel => channel,
    # 保存場所
    :output => "/opt/videos/%d" % issue.id
  }

  # ジョブ作成
  if create_job info
    # 実行中
    issue.status_id = 2
  end

  # 予約トラッカー
  issue.tracker_id = 2
  issue.save

  puts "ID:%d [%s]" % [issue.id, issue.subject]
}


以前の記事で作ったチケット操作での予約処理の方もジョブ登録機能を持つので直す。

対応したフックスクリプト video_hooks.rb
# -*- coding: utf-8 -*-

# 接続設定
Hudson.settings = {:url => 'http://localhost/jenkins', :crumb => false }

# ジョブ作成
def create_job info

  # Config
  return false if !Hudson::Job.list.include?("record_0")
  config = REXML::Document.new(Hudson::Job.get("record_0").config)

  # コマンド定義
  commands = [
              "/opt/task/ready.rb %d" % info[:issue_id],
              "/opt/task/record2.sh %d %d %s" % [
                                                 info[:channel],
                                                 info[:duration],
                                                 info[:output]
                                                ],
              "/opt/task/record2.rb %d" % info[:issue_id]
             ]

  # 時間設定
  config.elements["/project/triggers/hudson.triggers.TimerTrigger/spec"].text =
    "%s %s %s %s *" % [
                       info[:datetime][10..11],
                       info[:datetime][8..9],
                       info[:datetime][6..7],
                       info[:datetime][4..5]
                      ]
  # コマンド登録
  config.elements.each_with_index('/project/builders/hudson.tasks.Shell') {|shell, i|
    if i<commands.size
      shell.elements["command"].text = commands[i]
    end
  }

  # 作成
  job_name = "record_%d" % info[:issue_id]
  job = Hudson::Job.get job_name
  job = Hudson::Job.create job_name if job.nil?

  # 設定
  job.update config.to_s
  job.enable
  return true
end

# ジョブ削除
def delete_job id

  job = Hudson::Job.get "record_#{id}"
  if !job.nil?
    job.delete
    return true
  end
  return false
end

class VideoHooks < Redmine::Hook::ViewListener

  # チケット更新
  def controller_issues_edit_before_save(context)
    edit_hook context[:issue]
  end

  # バルク更新(チケットごとに呼ばれる)
  def controller_issues_bulk_edit_before_save(context)
    edit_hook context[:issue]
  end

  def edit_hook issue
    # 番組内のチケット
    if issue.project_id == 1
      do_cancel issue if issue.tracker_id == 1 || issue.status_id == 5
      do_reserve issue if issue.tracker_id == 2
    end
  end

  # キャンセル
  def do_cancel issue
    # ジョブ削除して、録画キャンセルチケットに
    if delete_job issue.id
      issue.status_id = 5
      issue.tracker_id = 1
    end
  end

  # 予約?
  def do_reserve issue

    time = issue.custom_field_values[0].to_s
    channel = issue.custom_field_values[1].to_s

    # 1分前にジョブがスタートするように
    datetime = Time.parse( issue.start_date.strftime("%Y%m%d") + format("%04d",time) ) + 60
    duration = issue.estimated_hours.to_i * 60

    # 時間調整
    nowtime = Time.now
    if datetime < nowtime
      if datetime + duration < nowtime
        # 終わってる・・・
        issue.tracker_id = 1
        return
      else
        # 時間が過ぎてる!後半だけでも!
        duration = duration - (nowtime - datetime) - 60
        datetime = nowtime + 60
      end
    end

    info = {
      # チケット番号
      :issue_id => issue.id,
      # 録画開始日時
      :datetime => datetime.strftime("%Y%m%d%H%M"),
      # 録画時間
      :duration => duration,
      # チャンネル
      :channel => channel.to_i,
      # 保存場所
      :output => "/opt/videos/%d" % issue.id
    }

    # ジョブ登録してチケットを実行中に更新
    if create_job info
      issue.status_id = 2
    end
  end

end

create_job関数の部分は共通になったので外部モジュール化ができそうだ。だけど何処に置くと使いやすいかなぁ。redmineプラグインのlibに置く? 外部タスクの所に置く? どっちを$LOAD_PATHに追加してrequireする?
この辺が固まってないんで、今のところはどっちにも記述しておくけど、こういうのがバグの元なんだよね。

駆け込み録画!
この記事を書いているときに突然思い立って追加したのがvideo_hooksスクリプト内の時間調整のところ。もう開始時間は過ぎてしまったんだけど、途中からでも残りを録画したいという予約時間調整をする部分。

ビデオファイル名をチケットIDに変更!
今まではファイル名はYYYYMMDDmmhh.mp4とかだったんだけれど、一分前処理とかしちゃったもんだから、番組情報と実際の時間が合わなくなった。もう思い切ってファイル名=チケット番号ってことにした。そうしたらあちこちシンプルになった。

今までのものもチケットにアタッチされているので、アタッチファイルからファイル名取得してれば、コンパチビリティも問題なさそう。

あちこちにバグがあって、時々変なことになるけど概ね良い感じに録画ができているかな。オリンピック録画チケットも着々と増えている。失敗もあるけど多くの原因はチューナデバイスの競合だ。これはこれで解決しなくては。

2012/08/10

過去の番組チケットと録画ジョブの後処理

jenkinsとrest-clientとjsonとhudson-remote-apiの組み合わせ問題

日々過去のものとなる番組チケットクローズと録画ジョブ削除を行う処理の中での話。
結構前から構築して動かしていたけれど、色々試行錯誤していて落ち着かなかった。すっきりはしてないけれど妥協点として一度書き記しておこうかと。

チケットのクローズ
これはシンプルに、開始日時が今日以前の番組チケットをクローズするっていうだけの処理。削除することもやってみたけれど、削除は負荷が高く、データベース的にもよろしくないらしいのでクローズフラグだけにしている。

close.rb

#!/usr/bin/ruby
# -*- coding: utf-8 -*-
#
require 'active_record'
require 'active_support/core_ext'

ActiveRecord::Base.establish_connection(
        :adapter => 'mysql2',
        :host => 'localhost',
        :username => 'redmine',
        :password => 'redmine',
        :database => 'redmine'
)

class Issue < ActiveRecord::Base
end

# 昨日までの開いてる'番組'チケットをクローズ(終了)する
Issue.find(:all, :conditions => ["tracker_id=1 and start_date<?", Date.today]).each {|i|
  i.status_id = 3
  i.save
}


Jenkinsジョブの削除
録画ジョブはワンショットなので、実行済みのジョブは不要だ。実行ログ確認のため数日間は残して、それ以降の古い録画ジョブを削除したい。これを行うのに結構試行錯誤してしまった。

過去の録画ジョブは成功失敗にかかわらず、一度はビルドされているはず。そのビルド日時を調べて数日前に行われたならば削除するようにしたかった。

hudson-remote-apiでビルドタイムスタンプへアクセス出来ない
これ便利なので全部これでやりたかったんだけれど、ビルド番号は取得できるけれど、ビルドのタイムスタンプへのアクセスができそうにない。

じゃあ使うのを諦めて、rest_clientで全部処理しようかと考えたが・・

/job/ジョブ名/doDeleteでエラーが
JenkinsのRemote APIでJenkins-path/job/ジョブ名/doDeleteが消すコマンド。これをRestClient.postで実行すればいいやと。だけど
/var/lib/gems/1.9.1/gems/rest-client-1.6.7/lib/restclient/abstract_response.rb:39:in `return!': 302 Found (RestClient::Found)
なんてエラーが出てアボートしてしまう。だけど削除はされる。うーん。色々手順踏まないとpostは正常に処理できないようだ。

'record'View内の録画ジョブだけをリストアップしたい
Hudson::Job.listとかだと全部列挙されてしまう。
Hudson::Job.find("recod_.*")とかやりたい。
Hudson::Job.listInView("record")とかview内のジョブリストを得たい。
ってのが出来ない。

結局、RestClientで取得することにした。

RestClient.get "http://jenkins/view/record/api/json"

って感じで今回はJSONで受けてみた。XMLでも大した違いはないし、hudson-remote-apiがREXML/documentをrequireしてるから、XMLでやった方がスマートだと思うけど。

気持ちは、hudson-remote-apiで全部やるか、rest-clientで全部やるか、だったけど上記のように、どっちも鎮座した。すぐには解決しそうにないんで、結果を優先して

  • ジョブ情報取得はrest-clientを使用
  • 削除はhudson-remote-apiを使用
  • 情報パースは気まぐれにJSONを使用

ということになった。

/job/ジョブ名/1/buildTimestamp をTime.parse 出来ない
ビルド#1のタイムスタンプを得るJenkinsのリモートコマンドから得られる日時文字列をそのままTime.parseするとエラーになってしまう。受け付けてくれないフォーマットなのだろう。
これだけのAPI使ってるのに自力てパースなんてやりたくないぞ。

/job/ジョブ名/1/api/json
で取得した中の'timestamp'を直接頂いて、Time.atに食わせるようにした。

こんな風に、どれもこれもが中途半端な感じですっきりしないんで公開するのをためらっていたが、ひとまずの結果として残しておこうと思う。

そんなこんなのJenkinsジョブ削除スクリプトがこちら。
close2.rb

#!/usr/bin/ruby
# -*- coding: utf-8 -*-
#
require 'hudson-remote-api'
require 'rest_client'
require 'json'

Hudson.settings = {:url => 'http://localhost/jenkins', :crumb => false }

# 5日前
some_days_ago = 60 * 60 * 24 * 5

if true
  # REST-APIでtimestampアクセス
  jobs = JSON.parse RestClient.get "http://localhost/jenkins/view/record/api/json"
  jobs['jobs'].each {|job|
    if job['color']=='blue'

      #timestamp = RestClient.get Hudson[:url] + "/job/#{job['name']}/1/buildTimestamp"
      #datetime = Time.parse(timestamp)
      # パースがうまく出来ないんで

      build = JSON.parse RestClient.get Hudson[:url] + "/job/#{job['name']}/1/api/json"

      datetime = Time.at( build["timestamp"].to_i / 1000 )

      if datetime + some_days_ago < Time.now

        #RestClient.post Hudson[:url] + "/job/#{job['name']}/doDelete",nil
        #/var/lib/gems/1.9.1/gems/rest-client-1.6.7/lib/restclient/abstract_response.rb:39:in `return!': 302 Found (RestClient::Found)
        #このエラーを解消できないんで

        jjob = Hudson::Job.get job['name']
        if !jjob.nil?
          jjob.delete
          puts job['name']
        end
      end
    end
  }

else
  # Hudson-remote-apiだけで出来ないかなぁ

  jobs = REXML::Document.new Hudson::HudsonObject.get_xml "http://localhost/jenkins/view/record/api/xml"

  jobs.elements.each('listView/job') {|j|
    job = Hudson::Job.get j.elements['name'].text
    if !job.nil? && job.color == 'blue'
      #p job.last_build
      # うーんと、ビルドタイムスタンプへ辿り着けないぞ
    end
  }
end


うまく出来なかった部分のコメントやら、いずれ、hudson-remote-apiだけで出きるようにならないかな?という期待を込めてのコードも残してあるんで、ちょっと読みにくいよ。

この2つのスクリプトを1日1回実行するJenkinsジョブを作って回している。が・・

Jenkinsで動かすと以下の様なエラーが
+ /opt/task/close2.rb
/usr/lib/ruby/1.9.1/json/common.rb:148:in `encode': "\xE9" on US-ASCII (Encoding::InvalidByteSequenceError)
 from /usr/lib/ruby/1.9.1/json/common.rb:148:in `initialize'
 from /usr/lib/ruby/1.9.1/json/common.rb:148:in `new'
 from /usr/lib/ruby/1.9.1/json/common.rb:148:in `parse'
 from /opt/task/close2.rb:15:in `
' Build step 'Execute shell' marked build as failure
普通に自分の環境でコマンド実行する場合は問題ないけど、Jenkinsジョブとして動かすとこんなエラーが出てしまう。気まぐれに使ったJSONが仇になったか。ちょうど'record'ビューの説明文に日本語を使っていたので、引っかかってしまった。

JSON自体はunicodeにもちろん対応しているけど、encodeの動作はLANG環境変数に依存しているようだ。解決方法は2つ。
  • シェルコマンドに "LANG=ja_JP.UTF-8 /opt/task/close2.rb" って個別に書くか
  • Jenkinsのシステム設定のグローバルプロパティで環境変数を設定するか
どっちでもいいと思うけど、また同じようなことで対応するのは面倒なのでJenkinsの設定でLANG環境変数を設定することにした。

2012/08/04

予約・キャンセルをredmine-plugin-hookで即時実行

チケット操作で予約・キャンセルの即時実行がやっと出来るようになった。
今までは1時間に1回実行している予約スクリプトでチケットトラッカーとステートを確認して録画ジョブ登録や削除をしていたけど、リアルタイム性がなかった。
いろいろ調べてredmineプラグインのフック機能を使えば、チケット操作した時点でジョブ登録・削除を行えるんじゃないかと。
実際にやってみたらいい感じだったので、その辺を書き記しておく。

使用したフック機能は2つ
1. controller_issues_edit_before_save
2. controller_issues_bulk_edit_before_save
1は、普通にチケット更新ページで色々変更した時に呼ばれるフック関数
2は、チケットリストビュー上で複数チケットを選択して右クリックポップアップメニューから属性1つを変更した時にチケットごとに呼ばれるフック関数。この方法が断然便利。

どんなフックがあるかは、Redmine plugin hooks listを参照した。
$ rake redmine:plugins:hook_list
ってやれば確認できるっていうことだけど、そんなビルドタスクはないよって言われた。redmine2.0だとやり方違うのかもね。

クラス継承するものが以前とちょっと違うんだね。以前は
Redmine::Hook::Listenerを継承するんだったはずだけど、新しいredmineでは
Redmine::Hook::ViewListenerを継承するのだそうだ。微妙な違いだけどちょっと悩んだよ。


lib/video_hooks.rb
フックスクリプトは以前視聴ビュー生成のために作ったredmine_videoプラグイン内に追加することにした。フックするリプとはプラグイン内のlibに入れておけばいいようだ。

そして、init.rbの先頭に
require 'video_hooks'
って記述しておくとフックがかかるようになる。

使用するGEMモジュール
hudson-remote-api
前回記事でも登場したJenkinsにREST-APIアクセスする便利ラッパーを使用する。
rexml/document
これもJenkinsジョブ設定XMLを操作するために使用する。

フックスクリプトに、require 'hudson-remote-api'って書いたら見つからないって怒られた!
??パスが通っていないってことか?と思って、直接パスで
$LOAD_PATH << "/var/lib/gems/1.9.1/gems/hudson-remote-api-0.6.0/lib"
require 'hudson-remote-api'
ってやってみたら、読んでくれるようになったが・・・うーむ。こうじゃないよねぇ。

調べてみてもこの辺のことを明確に説明している記事とかが見つからない。
redmine/config/enviromnent.rbで$LOAD_PATHに追加しろとか、redmine/Gemfileに追加しろとか・・・ん?Gemfile?
redmine/Gemfileを覗いてみたら、最後のところでプラグイン内のGemfileがあったらそれも読むよって感じになってるぞ?なるほど。そういうことか。

自分のプラグインにGemfileを用意して
gem 'hudson-remote-api'
って1行記述。そうしたら、スクリプト内でrequireする必要なく使えるようになった。
今更ながらGemfileの存在意味が少し理解できた気がする。でも分かりにくいなぁ。

因みにrexmlは、requireもGemfile記述も必要なく使えた。

これでやっと中身が作れる。出来上がったフックスクリプトがこちら
video_hooks.rb
# -*- coding: cp932 -*-

Hudson.settings = {:url => 'http://localhost/jenkins', :crumb => false }

class VideoHooks < Redmine::Hook::ViewListener

  # チケット更新
  def controller_issues_edit_before_save(context)
    edit_hook context[:issue]
  end

  # バルク更新(チケットごとに呼ばれる)
  def controller_issues_bulk_edit_before_save(context)
    edit_hook context[:issue]
  end

  def edit_hook issue
    # 番組内のチケット
    if issue.project_id == 1
      do_cancel issue if issue.tracker_id == 1 || issue.status_id == 5
      do_reserve issue if issue.tracker_id == 2
    end
  end

  # キャンセル
  def do_cancel issue
    job = Hudson::Job.get "record_#{issue.id}"
    if !job.nil?
      # 予約されていたらジョブ削除してチケットを番組キャンセルに更新
      job.delete
      issue.status_id = 5
      issue.tracker_id = 1
    end
  end

  # 予約?
  def do_reserve issue
    config = configure issue
    if !config.nil?
      # Jenkinsジョブ登録してチケットを実行中に更新
      job_name = "record_#{issue.id}"
      job = Hudson::Job.get job_name
      job = Hudson::Job.create job_name if job.nil?
      job.update config
      job.enable
      issue.status_id = 2
    end
  end

  def configure issue
    return nil if issue.custom_field_values.nil?

    # Configure
    job0 = Hudson::Job.get("record_0")
    return nil if job0.nil?

    config = REXML::Document.new job0.config
    return nil if config.nil?

    time = issue.custom_field_values[0].to_s
    channel = issue.custom_field_values[1].to_s
    duration = issue.estimated_hours.to_i * 60
    datetime = issue.start_date.strftime("%Y%m%d") + format("%04d",time)
    output = "/opt/videos/#{datetime}"

    # コマンド定義
    commands = [
                "/opt/task/record2.sh %d %d %s" % [
                                                   channel.to_i,
                                                   duration,
                                                   output
                                                  ],
                "/opt/task/record2.rb %d" % issue.id
               ]
    trigger = "%s %s %s %s *" % [
                                 datetime[10..11],
                                 datetime[8..9],
                                 datetime[6..7],
                                 datetime[4..5]
                                ]
    # 時間設定
    config.elements["/project/triggers/hudson.triggers.TimerTrigger/spec"].text = trigger

    # コマンド登録
    config.elements['/project/builders/hudson.tasks.Shell[1]/command'].text = commands[0]
    config.elements['/project/builders/hudson.tasks.Shell[2]/command'].text = commands[1]

    return config.to_s
  end
end

フックの口は2つだけど、やることは同じなので、何方からも'edit_hook'メソッドへ飛ばす。
キャンセルなのか予約なのか判断して、それぞれのメソッドに飛ばす。

do_cancel
チケットに対応した録画ジョブがあればhudson-remote-api使って削除して、チケットを番組チケットにする。ステータスはキャンセル状態にする。理由は自動予約チケットだったら自動で再予約されないようにロックしたいから。

do_reserve
こっちは前記事のreserve2.rbから移植したもので、Jenkinsジョブを登録してチケットを実行中にする処理を行う部分。カスタムフィールドへのアクセス方法が外部タスクとやり方が違う。
何がどう変化したのか?というトリガーではなくどういう状態?というステートでしか判断できないので予約チケットを更新すると実行されてしまうけど、手動で録画時間などを変更したりする場合もこれで行けるだろうから、よしかな。

プラグインと外部タスクで処理モジュール共有したいけど、チケットへのアクセス方法とかが微妙に違うんで難しいな。Jenkinsジョブ処理部分だけでも整理しようかな。やる気になったらかな。

とにかく、これで即時実行が出きるようになったので、1時間毎に動かしていた予約スクリプトはクエリー予約だけ1日1回動けばいいだけになったので、その部分も変更しなくては。
次回はそのあたり。