ラベル redmine の投稿を表示しています。 すべての投稿を表示
ラベル redmine の投稿を表示しています。 すべての投稿を表示

2012/09/27

MediaElement.jsでビデオ視聴

MediaElement.jsをredmine上のビデオ視聴プラグインに組み込んでみた

初めに思ったのは、まだ見てないやつとか見たやつとか分かるように出来ないかな?ってことでした。ビデオの再生ボタンを押したことを検出して、データベースに視聴済みと記録できないかな?って。
更には、どこまで観たかを記録できれば途中再生もできるよねとか、ビデオコントローラをカスタマイズして15秒スキップとかできればCMスキップも楽だよねとか、視聴する部分の拡張をしてみたいと。

上記のようなことは、まだまだ出来そうにないんですが、まずはそういったことがやりやすそうなフレームワークを探してみたところ、有名どころで Video.jsとMediaElement.js がありました。
どちらもコントローラのカスタマイズとかイベントAPIとかを持っていて色々出来そう。

これらのドキュメントを読んでみると、どんなブラウザでも再生できるようにすることが目的になっているようです。そういえば自分のシステムでは素のHTML5のvideoタグでソースはH264だけなのでFirefoxとかでは観れない。
このフレームワークを使用するとH264だけでもFirefoxで見れるようになると。
うーん素晴らしい。
どうして見れるんだ?ってことですがH264を再生できないブラウザでは自動的にプレーヤープラグインを通して再生するってことらしい。そのためのプラグインが内包されています。

Video.js

簡単そうだったので、初めはこっちを使ってみましたが、ドキュメント通りにやってみて、CDNホストバージョンだろうがセルフホストだろうが、Firefoxで視聴できるように出来ませんでした。
セルフホスト用をダウンロードすると、video-js.swfというのが入ってるんで、勝手に使ってくれるんだよね?って思っていましたが、甘かったようです。よくよく読んでみるとH264だけで大丈夫とは書いてない・・・
ということで、さようならです。

MediaElement.js

結果から言うと、特に色々やることなくFirefoxでは勝手にブラウザプラグインが使われて、ソースがH264だけでも再生してくれました。更にビデオイベント取得やビデオオブジェクト操作APIや、何やらカスタムコントローラにボタン追加したり出来てしまうらしい。冒頭に書いたようなことが出来そうな可能性を感じます。

ダウンロード

こちらは、フリーCDNホストはないので、パッケージをダウンロードして使用します。
$ wget http://github.com/johndyer/mediaelement/zipball/master
ってやるとダウンロードされますが、ファイル名が'master'になっちゃいます。でも気にせず
$ unzip master
で解凍して'master'はポイです。

中身を覗いてみると、/buildにフレームワーク一式。デモサンプルのHTMLとメディアファイル、Silverlight用とFlash用のプラグインのビルドファイルとソースコード一式がどっさり入っておりました。


視聴システムへの組み込み

視聴部分はredmineプラグインで構築しているので、言うなればRailsアプリへの組み込みってことになります。ビデオ視聴プラグインの一部として組み込みたいので、必要なファイルをそっちへ移動させます。
ここでは、ビデオ視聴プラグインのフォルダを、/redmine_videoとして記述しておきます。

JavaScriptとプラグインをassets/javascriptsへコピー
MediaElement/build/*.js => /redmine_video/assets/javascripts
MediaElement/build/flashmediaelement.swf => /redmine_video/assets/javascripts
MediaElement/build/silverlightmediaelement.xap => /redmine_video/assets/javascripts

cssとイメージファイルをassets/stylesheetsへコピー
MediaElement/build/*.css => /redmine_video/assets/stylesheets
MediaElement/build/*.png => /redmine_video/assets/stylesheets
MediaElement/build/*.gif => /redmine_video/assets/stylesheets

Railsではassetsフォルダの中をjavascripts/stylesheets/imagesに分けることになってますが、MediaElementはフラットなので、このような分け方にしました。全部assets/javascriptsに入れてしまっても動くでしょうけどrailsでシンプルに書きたいんで。

redmineを再起動すると、redmine_video/assetsが、redmine/public/plugin_assets/redmine_video/へ自動コピーされて使用できる状態になります。

最初なぜかredmine/public/plugin_assets/redmine_video/がルートパーミッションになってしまっていてコピーでエラーになってしまい、ちょっと悩みました。なんで?誰がやった?自分??原因不明。

ビュー作成

視聴ビューとしていた以前のplay.html.erbは以下のようなものでした。
<h2><%= @issue.subject %></h2>

<% video = @issue.attachments.detect {|a| a.content_type == "video/mp4"} %>
<% thumb = @issue.attachments.detect {|a| a.content_type == "image/jpg"} %>

<%= video_tag video.disk_filename, :id => "video",
    :poster => image_path("/videos/"+thumb.disk_filename),
:autoplay => true, :autobuffer => true, :controls => true, :size => "960x540" %&gt\
;

これを、MediaElement.jsのデモサンプルやらサイトやら色々物色して、不要なものをすべてそぎ落として、Railsで書き直したら以下のようになりました。

<% content_for :header_tags do %>
  <%= javascript_include_tag "jquery.js", :plugin => 'redmine_video' %>
  <%= javascript_include_tag "mediaelement-and-player.min.js", :plugin => 'redmine_video\
' %>
  <%= stylesheet_link_tag "mediaelementplayer.min.css", :plugin => 'redmine_video' %>
  <%= stylesheet_link_tag "mejs-skins.css", :plugin => 'redmine_video' %>
<% end %>

<% video = @issue.attachments.detect {|a| a.content_type == "video/mp4"} %>
<% thumb = @issue.attachments.detect {|a| a.content_type == "image/jpg"} %>

<%= video_tag video.disk_filename, :poster => image_path("/videos/"+thumb.disk_filename)\
,
:type => "video/mp4", :size => "960x540" %>

<%= javascript_tag "$('video').mediaelementplayer()" %>

最後のmediaelementplayer()が全て色々よろしくやってくれるようです。
この関数はイベントリスナーやら数あるオプション設定やらいろいろカスタマイズできます。

ここまで到達するのに悩んだことがいくつかありました。
  • content_for :head じゃなくて、content_for :header_tagsだった。
    railsのドキュメントには:headって書いてあるんだけどなぁ。かなり悩んだ。
  • javascript_include_tagやstylesheet_link_tagに、:pluginオプションが必要だった。
    railsドキュメントではなく、redmineプラグインドキュメントを読むべきだった。
    これにより、プラグインのassets以下を指定したことになるらしい。
  • video_tagには、:type => "video/mp4"が必要だった。
    これがないとMediaElement.jsが全く機能しなかった。
    MediaElement.jsのセットアップで、サイトにAddTypeでMIMEを追加せよと書いてあったけど、やっていない。もしかしたらそのため? でもこれで動いたんで大丈夫。
これで、やりたかったことの一つ、Firefoxで視聴できるようになりました。
実際にやってみると、付属のSilverlightプラグインを通して再生されているようです。

次回は、もう少し突っ込んだ使い方を書いてみたいと思ってますが、実はなかなかうまくいっていません。よくわからない状態継続中です。また暫く間が開くかもです・・・

2012/07/22

カスタムクエリで番組予約、REST-APIでクエリ実行

Redmineはチケットを様々なフィルタリング条件で検索が出来る。
そして、それをカスタムクエリとして保存することが出来る。
カスタムクエリごとに、一覧に表示したいフィールドも設定できる。

既に番組検索で、以下の様なカスタムクエリを作って活用している
  • カテゴリごとのチケット一覧。映画とかスポーツとか。
  • ゴールデンタイムの70分以上の番組。(スペシャル番組かな)
  • 宇宙や科学に関する番組一覧(趣味です)
  • 予約チケット一覧(ここではjob_idも表示するようにしている)
  • 録画チケット一覧
こんな風に自分条件のクエリをどんどん作って利用していける。

カスタムフィールドの開始時間を文字列じゃなく数値タイプにしているために
0時10分とかだと、「10」って表示されて分かりにくいが、
クエリで数値フィルタが使えるようにするために敢えて数値タイプにしている。
出来れば、「00:10」って表示されて欲しいけどね。いずれ解決できるかな。

毎週予約
wavecastでも毎日とか毎週とかの設定は出来る。そのとおり実行はしてくれるけど、題目が毎週変わるとか、今週は休みとか、時間がちょっとずれてるとかには対応できていない。
epgrecでは条件設定が出来て、条件に合うものが、その都度予約されて便利だった。

スペシャル番組
特に毎週予約よりも重要なのがスペシャル番組だ。毎年恒例のスペシャルとか録り逃すことがあると(特に「はじめて○おつかい」とか)、カミさんに怒られる。あ~あ・・あ~あって。
そういうものには、キーワード予約が大活躍なはず!

これからオリンピック
番組表を眺めながら探すのは結構苦労するだろう。
そんな時にも、様々な条件設定をしたクエリを作っておいて、合致した番組を自動的に予約設定する仕組みにしておけば楽ちんだ。

予約クエリは公開クエリ
カスタムクエリには、公開と非公開というタイプがある。一般クエリは非公開、予約クエリは公開に設定して区別することにする。他に簡単に区別できる属性がなかっただけだけど。

ここまで引っ張る話でもなかったけど、勢いで書いてしまいました。

後はスクリプトでどうにかするんだけど・・・

外部スクリプトからどうやってクエリすればいいんだろ?
クエリレコードを完全に理解してActiveRecordで頑張る?ちょっと調べてみたけど複雑でかなり無理がありそう。

REST-APIでクエリ実行
いわゆるWEB-APIと言われるやつですね。Redmine独自の動作をさせるには、こういった方法が手っ取り早そうなので、使うことにしました。
GETだけではなく、POSTでチケット作ったり更新したり出来るらしいけれど、あまり具体的な情報がないので、いじり倒すのはまた今度の機会に。

RESTによるWebサービスを有効にする
Redmineが外からのWebAPIを受け付けるように、「管理」の「認証」ページで有効にしておく。

ユーザのAPIアクセスキー
外部アクセスするユーザのAPIアクセスキーが必要なので、作っておく。作り方は簡単。
ユーザの個人設定のサイドバーにAPIアクセスキーってところでリセットと表示が出来る。
表示して内容をスクリプトにコピペして使用する。
まだこのRedmine上に一般ユーザを作ってないので「admin」のAPIキーを使っている。

rest-client
これを利用すれば、rubyから簡単にRESTアクセスが出来るようになる。
インストール
$ sudo gem install rest-client
rubyで使うときは、
require 'rest_client'
RestClient.get "URL"
で結果を受けっ取れる。
URLは、REST-APIコマンドとAPIキー設定が必要。今回はこれだけ知っていれば使える。

自動予約スクリプト
いつものように、ActiveRecordを使ってQueryから公開クエリを検索して、REST-APIでクエリ実行して、結果をハッシュに変換して、ヒットしたチケットIDをリストする。そのチケットIDリストから検索されたチケットのトラッカーを「予約」に変更して保存する。

query.rb (ファイル名が安易だ)
#!/usr/bin/ruby
# -*- coding: utf-8 -*-

require 'rubygems'
require 'active_record'
require 'rest_client'

key = 'redmineで作ったAPIキーをここに'

# 特定のクエリ実行するAPI
api = "http://localhost/projects/%s/issues.xml?key="+key+"&query_id="

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

class Issue < ActiveRecord::Base
end

class Query < ActiveRecord::Base
end

class Project < ActiveRecord::Base
end

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

# 予約クエリー検索
reserve = []
queries = Query.find :all, :conditions => { :project_id =>1, :is_public =>1 }
queries.each {|query|
  # REST-APIでクエリー実行
  result = Hash.from_xml( RestClient.get api+query.id.to_s )['issues']
  result.each {|i|
    if i['tracker'] ['id'].to_i == 1
      reserve << i['id'].to_i
    end
  }
}

Issue.find(reserve).each {|issue|
  issue.tracker_id = 2
  issue.save
  puts "ID:%d [%s]" % [issue.id, issue.subject]
}
プロジェクト識別名も決まってるから直に書いても良かったんだけれど、IDから検索して識別名を取得している。いずれハードコードなところを直していく時の覚書みたいなもの。

最初簡単だろうと思ってたけど、結構試行錯誤した。そして更に問題が増えた。

チューナ1つであることを考慮してないんで、こんな自動化だと重なった時には録画失敗する。
オリンピック番組は題名だけじゃなくて内容も検索条件に入れないと絞り込めないだろうね。
普通の操作ではチケット内容部分は検索条件に入れられないのだ。どうしようかな。

この辺の現実的問題と肝心の視聴部分が、いよいよとなって参りました。
どうしようかな・・・

録画後のredmineチケット処理

地デジ番組情報をチケット登録
チケットの録画予約
予約チケットの録画
「録画チケット処理」

めでたく録画された後のチケット処理についても、ActiveRecordで書いてみた。

録画されたはいいが、どうやって視聴しよう??っていうのが今悩み中だけど、その前に取り敢えず無事に録画されたよっていうチケットことにしておこうと思って、このフェースを踏んでいる。

こんなことを考えて制作している。
  1. 予約した番組の放送時間を過ぎたら、’予約’を’録画’にトラッカーを変更
  2. 無事エンコードファイルが完成したら、チケットの添付ファイルとして登録する
  3. 問題なければ、チケットステータスを、’完了’にする
こんな感じにして、Redmineから録画トラッカーチケット一覧すれば見やすいかなと。

最後にスクリプト全体を記載するとして、部分的な説明を書いておく。

放送時間が過ぎたチケットの検索
Issue.find( :all, :conditions => ["tracker_id=2 and start_date<=?", Date.today]).each {|issue|
  if on_air_time(issue) < Time.now

最近検索でちょっと悩む。:conditions => ["条件式"] か、:conditions => {ハッシュ}かで。どっちでもいいみたいだけど、ハッシュの方で条件式が書ければすっきりするんだけどなと・・

on_air_time関数
チケットから放送開始時間をTimeで返す関数
カスタムバリューからチケットカスタムフィールド’開始時間’を取得して開始日に加算。
2行程度の簡単関数だけど、もっとすっきりかけないかなぁ。

録画ファイルのアタッチ
Redmineでは、1添付ファイルごとにAttachmentsというレコードで記録されている。
アタッチレコードに追加してチケットと関連付けると、チケットの添付ファイルになる。

追加スクリプト
      Attachments.create( :container_id => container_id,
                          :container_type => "Issue",
                          :filename => filename,
                          :disk_filename => filename,
                          :filesize => File::stat( @media_path + filename ).size,
                          :content_type => type,
                          :digest => Digest::MD5.hexdigest(filename),
                          :author_id => 1,
                          :description => desc
                          )

普通のWeb操作で添付ファイルをつけると、何やらユニークコードがオリジナルファイル名の前に追加されて/filesに保存されていく。保存場所が/filesだけなので同じファイル名でも別のファイルとして識別する必要があるためだろう。
だけど今回は元からほぼユニークファイル名なので、disk_filenameも普通のファイル名で登録している。
Redmineのファイルモジュールでしか使わないであろうDigestも一応入れているけれど、かなり適当。ファイル名でしかDigestを作ってない。多分活用しないから、これでいいかと。ホントはDigestコードである必要もないかも。

ほんとに添付しているわけではない
添付って言うと、Redmineのファイル管理フォルダ'files'へ転送して・・・
っていうイメージだけれど巨大なデータファイルを扱うので現実的じゃない。
もっと簡単な方法。

一旦、/opt/redmine/files フォルダを削除して
$ sudo ln -s /録画ファイル保存場所 /opt/redmine/files
で保存場所=redmiine/filesにするだけです。


なぜアタッチ?
今のところの理由は、アタッチしておけば、そのチケットを削除するとアタッチファイルも勝手に削除してくれるのでファイル管理が楽ちんっていうところでしょうか。

一応、サムネールファイルも作ってアタッチしてるんで、どんなかな?とイメージだけ確認できるし。
Redmineに添付ファイルを勝手にサムネール表示してくれるプラグインとかもあるんで、入れると少し便利かも。

でも、添付された本体ビデオファイルをチケットからクリックしても視聴できるわけじゃない。
実際やると固まってしまう・・・ブラウザがダウンロードしようとしてるんだと思う。恐ろしい・・・
この辺が次のテーマかな。

完了?
サムネールが添付されたら無事に終わったということにして、チケットを’完了’ステータスにしてセーブする。サムネールがまだ出来てないなら処理中だと判断する。

そんなこんなをするスクリプトがこちら
record.rb
#!/usr/bin/ruby
# -*- coding: utf-8 -*-
#
require 'rubygems'
require 'active_record'
require 'active_support/core_ext'
require 'digest/md5'

@media_path = '/opt/redmine/files/'

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 Attachments < ActiveRecord::Base
end

def attach( desc, container_id, filename, type )
  if FileTest.exists?( @media_path + filename )
    if Attachments.first( :conditions => {
                            :container_id => container_id,
                            :digest => Digest::MD5.hexdigest(filename)
                          }).nil?
      Attachments.create( :container_id => container_id,
                          :container_type => "Issue",
                          :filename => filename,
                          :disk_filename => filename,
                          :filesize => File::stat( @media_path + filename ).size,
                          :content_type => type,
                          :digest => Digest::MD5.hexdigest(filename),
                          :author_id => 1,
                          :description => desc
                          )
      return true
    end
  end
  return false
end


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

# 時間が過ぎた予約チケット
Issue.find( :all, :conditions => ["tracker_id=2 and start_date<=?", Date.today]).each {|issue|

  if on_air_time(issue) < Time.now
    # 録画トラッカーへ
    issue.tracker_id = 3
    issue.save
  end
}

# 実行中の録画チケット
Issue.find( :all, :conditions => {:tracker_id => 3,:status_id => 2}).each {|issue|

  on_time = on_air_time issue

  # アタッチ
  if attach "サムネール", issue.id, on_time.strftime("%Y%m%d%H%M") + '.jpg', "image/jpg"
    attach "ビデオファイル", issue.id, on_time.strftime("%Y%m%d%H%M") + '.mp4', "video/mp4"
    # 録画完了
    issue.status_id = 4
    issue.save
    puts "Completed: #"+issue.id.to_s
  else
    puts "Running or Failed: #"+issue.id.to_s
  end
}

これも前回の予約実行スクリプトと同じようにJenkinsで定期的に実行しておく。

次回は、視聴に取り掛かりたいが、めどが立ってないので、キーワード予約とか過去の番組チケット整理とかについて書いておこうと思う。

2012/07/20

Redmineで番組予約、atで録画、Jenkinsで監視

番組データからredmineチケット作成まではなんとか自動化出来た。
次は、予約~録画~監視までの流れを構築する。

’予約’トラッカー
当初は、チケットステータスで、新規・予約・録画・・・ってやろうと思ったけれど
ヤメた。録画に必要な番組情報をカスタムフィールドで持つことにしたのだが、これらはトラッカーに紐付いているので、ステータス表現だと都合が悪い。トラッカーで表現する事にした。

録画予約したい番組チケットをredmine上でトラッカーを’予約’に変更すればよい。
という事にした。

予約専用カスタムフィールド :job_id
以前の記事:RedmineとActiveRecordで番組チケット作成

* 録画開始時間(数値)
* チャンネル(文字列)
* ジョブ番号(数値):録画ジョブ管理番号

って考えていたが、ジョブ番号は、予約トラッカーだけが持つものにした。
録画ジョブが実行されるのは予約したチケットだけなわけだから。

録画ジョブ
暫定ですが、’at’で行うようにしてみる。(思うところあって、暫定です)

atで録画スクリプト実行を登録するコマンド
echo 'CH=[チャンネル] LEN=[長さ] OUT=[ファイルパス] record.sh'|at -t [開始時間]
こんな感じ。atコマンドは実行内容をファイルで受け取るか標準入力から受け取るか、なので
ファイルにしたくなかったんで、echoとパイプで標準入力から入れるように書く。

こういう形になるように、録画予約スクリプトを記述すれば良い。
ただ、後でちょっと変更するはめになった(後述)。

そして、これから用意する予約スクリプトでは、予約チケットを検索して、番組時間に録画が開始されるようにatジョブを登録して、ジョブIDをチケットに記録しておく処理を行えばいい。

atのjob-id取得
予約スクリプト内でatを実行するわけだが、そのJOB番号をスクリプト内で簡単に取得する方法でちょっと悩んだ。`at`とかsystem("at")とかexec("at")じゃとれないし・・・欲しい情報は標準エラーに出力されているのだ。色々調べた結果、シンプルに取得することが出来た。
result,_s = Open3.capture2e( command )
result[/\d* at/].to_i
open3のcapture2eは標準出力と標準エラー出力を混ぜて返してくれる。
2行目で”・・・ [ID] at ・・・”な感じの文字列から"数値 at”にする。最初が数値ならそのまま.to_iすれば数値だけが得られる。"数値"な文字列にするところまで頑張らなくてもいい。

あっ。スクリプト言語はRubyです

Jenkinsで監視
atジョブの結果をJenkinsで管理したい。
Jenkinsで「外部ジョブの監視」っていうジョブタイプでジョブ名を「atjob」って事にして作成しておく。
で、外からJenkinsサーバーのこのatjobに対して結果をXMLでPOSTすればいいらしい。
それを簡単に行うためのコマンドが Jenkins-core だそうな。

えーと、jenkins-coreはどこかいな・・・
マニュアル読んでも、どこかの記事にも具体的なパスが書いてないぞ?
/var/lib/jenkins ?違うなぁ。
/usr/share/jenkins ? jenkins.warならここにあるけど、この中から引っこ抜くのか?

あったあった
$ ps ax|grep jenkins (プロセスを探すときによくやるフレーズ)
でみてみると
/usr/bin/java -jar /usr/share/jenkins/jenkins.war --webroot=/var/run/jenkins/war --httpPort=8080 --ajp13Port=-1 --preferredClassLoader=java.net.URLClassLoader --prefix=/jenkins --logfile=/var/log/jenkins/jenkins.log

ってなってる。--webroot=/var/run/jenkins/war だって。
/var/run/jenkins/war に、jenkins.war が展開されていた。
jenkinsをapt-getでインストールしたんで、不思議なところがJenkinsのwebrootになってた。

ということで、これですな。
/var/run/jenkins/war/WEB-INF/lib/jenkins-core-1.424.6.jar

java -jar /var/run/jenkins/war/WEB-INF/lib/jenkins-core-*.jar atjob [コマンド]
で、コマンドの結果がjenkinsのatjobで監視できるということか。
-*.jarにしてるのはバージョンが変わっても変更しないでいいように濁しておく。

さきほどの録画コマンドをatジョブに登録する処理が以下のようだとして
echo 'CH=%d LEN=%d OUT=%s record.sh'|at -t '%s'

jenkins-coreをかぶせる
echo 'java -jar /var/run/jenkins/war/WEB-INF/lib/jenkins-core-*.jar atjob "echo 'CH=%d LEN=%d OUT=%s record.sh'|at -t '%s'"

echo 'CH... そんなコマンドないって怒られる。echoはシェルコマンドだからか?
atならシェルコマンドとして解釈されていたが、jenkins-coreはjavaだからダメか?
これに更にシェルを被せるのはちょっと気が引けるな。どうしようか。
仕方ない。
録画コマンドシェルを環境変数じゃなくて引数で受け取るように変更だ。

echo 'java -jar /var/run/jenkins/war/WEB-INF/lib/jenkins-core-*.jar atjob record2.sh %d %d %s'|at -M -t '%s'

こうだね。record2.shが改良版ってことで。
%d %d %sは、スクリプト内で値を渡すところ。

実行・・・おっと、別の原因でだめだった。
jenkinsサーバーのURLをJENKINS_HOMEという環境変数で渡すらしい。

で完成したのが
echo 'JENKINS_HOME=http://localhost/jenkins java -jar /var/run/jenkins/war/WEB-INF/lib/jenkins-core-*.jar atjob record2.sh %d %d %s'|at -M -t '%s'

こんな感じでatジョブに登録すると、at内で

JENKINS_HOME=http://localhost/jenkins java -jar /var/run/jenkins/war/WEB-INF/lib/jenkins-core-*.jar atjob record2.sh %d %d %s

が実行される。
record2.shの出力結果がjenkins-coreを通してjenkinsの'atjob'ジョブにポストされる。

やってみると、録画からエンコードまでの出力がすべてログされる感じになった。
だけど、失敗してもjenkinsジョブには失敗として伝わらなかった・・・うーん。
atジョブ自体がどうかなぁと思っているんで、今はこれ以上の追求はやめて、jenkinsでログが取れただけでもよしとしよう。

最終的に、redmineの予約チケットから情報を取得して、atジョブがまだ登録されていなければ、上記のコマンドで登録して、atのジョブIDをチケットに記録するスクリプトがこちら

resserve.rb
#!/usr/bin/ruby
# -*- coding: utf-8 -*-
#
require 'rubygems'
require 'active_record'
require 'active_support/core_ext'
require 'open3'

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

class Issue < ActiveRecord::Base
end

class CustomValues < ActiveRecord::Base
end

# ジョブ登録
def entry_job info

  # コマンド
  command = "echo 'JENKINS_HOME=http://localhost/jenkins java -jar /var/run/jenkins/war/WEB-INF/lib/jenkins-core-*.jar atjob record2.sh %d %d %s'|at -M -t '%s'" %
    #command = "echo 'CH=%d LEN=%d OUT=%s record.sh'|at -t '%s'" %
    [ info[:channel],
      info[:duration],
      info[:output]+info[:datetime],
      info[:datetime]
    ]

  # 登録実行
  result,_s = Open3.capture2e( command )
  p command, result

  # ジョブID
  result[/\d* at/].to_i
end

# 予約チケット
issues = Issue.find( :all, :conditions => {:tracker_id => 2})

issues.each {|issue|
  # job_id
  job_id = CustomValues.first( :conditions => {
                                 :customized_id => issue.id,
                                 :custom_field_id => 3})

  # job_idアトリビュートが存在しない(queryから登録された)
  if !job_id
    job_id = CustomValues.create( :customized_type => "Issue",
                                  :customized_id => issue.id,
                                  :custom_field_id => 3, # job_id
                                  :value => "" )
  end

  # 未登録?
  if job_id.value.to_i == 0
    time = CustomValues.first( :conditions => {
                                 :customized_id => issue.id,
                                 :custom_field_id => 1}).value.to_s
    channel = CustomValues.first( :conditions => {
                                    :customized_id => issue.id,
                                    :custom_field_id => 2}).value.to_i

    info = {
      # 録画開始日時
      :datetime => issue.start_date.strftime("%Y%m%d") + format("%04d",time),
      # 録画時間
      :duration => issue.estimated_hours.to_i * 60,
      # チャンネル
      :channel => channel,
      # 保存場所
      :output => "出力パス"
    }

    # ジョブ 記録
    job_id.value = entry_job info
    job_id.save if job_id.value.to_i > 0

    # ステータス更新
    issue.status_id = 2
    issue.save
  end
}

色々ハードコードだったり、record2.shへのパスとか出力パスとかを濁してあるんで、ご参考までに。

とにかくこれを、jenkinsで毎時*時50分に実行するようにした。
大体録画したいやつは〇〇時00分とかが多いので、10分前までに予約すれば
録画ジョブ登録のタイミングに間に合うって感じだ。
この辺もちょっとなぁと思っているけど、今はこれでいい。

録画シェルコマンドがこちら
record2.sh [channel] [録画時間(分間)] [出力ファイルパス]
#!/bin/sh

CH=$1
LEN=$2
OUT=$3

echo "CHANNEL : $CH"
echo "LENGTH  : $LEN"
echo "OUTPUT  : $OUT"

TMP=$(tempfile)

# record
recfsusb2n --b25 $CH $LEN ${TMP}.ts

# encode
ffmpeg -y -i ${TMP}.ts -c:v libx264 -c:a libfaac -preset superfast -b:v 1800k -s 960x540 -aspect 16:9 -f mp4 -threads 0 ${TMP}.mp4

# corresponding to iPod
MP4Box -ipod -inter 500 ${TMP}.mp4 -out ${OUT}.mp4

# thumbnail
ffmpeg -i ${OUT}.mp4 -ss 12 -vframes 1 -an -s 320x180 ${OUT}.jpg

#FILE=`echo $OUT|sed s/^.*[/]//`
#ffmpeg -y -i ${OUT}.mp4 -threads 0 -s 480x272 -acodec copy -vcodec mpeg4 -b:v 900k -qmin 3 -qmax 5 -f mp4 ${OUT}-s.mp4

# Cleanup first!
rm ${TMP}*
これは、以前の記事:ひとまず録画できるところまで構築 から若干手を加えた感じのもの。
それほど変わってないけど。

ふぅ。何とか録画までたどり着いた。だけど、色々問題を残してるな。
その辺は追々じっくりと解決して行きたい。

次回は、録画した後処理についてかな。

2012/07/15

番組データ取得をjenkinsで自動化

録画したTSファイルからEPGデータを取得してredmineチケットにする事は出来るようになった。今度はそれを自動化する必要がある。必要はないか。でも毎日お手手で実行とか馬鹿げてるしね。機械的なルーチンワークはシステム化するのが普通だよね。

Jenkins
CIツールであるjenkinsを使って自動化する事にした。
Jenkinsは以前はHudsonと呼ばれていた、Webベースのビルド自動処理サーバーだ。
通常アプリ開発の効率化とかで便利なシステムだが、今回は番組録画システムの一部として使用してみる。


インストール
https://wiki.jenkins-ci.org/display/JENKINS/Installing+Jenkins+on+Ubuntu
に色々書いてありますが、
$ sudo apt-get install jenkins
だけで済んだよ。ずいぶん前は結構苦労したような・・・

セットアップ
ドキュメントルートはredmineになっているんで、http://localhost/jenkinsでアクセスできるように若干の設定が必要だ。

/etc/default/jenkins ファイルの最後のJENKINS_ARGSに"--prefix=/jenkins"を追加
JENKINS_ARGS="~~ --prefix=/jenkins" って感じに。

/etc/apache/sites-enables/redmineのVirtualHost内に以下を追加
# Jekins
ProxyRequests Off
ProxyPreserveHost on

Order deny,allow
Allow from all
ProxyPass http://localhost:8080/jenkins
ProxyPassReverse http://localhost:8080/jenkins

リスタート
$ sudo service jenkins restart
$ sudo service apache2 reload

これでおしまい。

jenkinsユーザをvideoグループに追加
jenkinsをインストールするとjenkinsユーザが作成され、ジョブはjenkinsユーザで実行される。
使用している録画ツール'recfsusb2n'を実行するにはvideoグループに属していなければならないので、jenkinsユーザをvideoグループに追加した。

実行ユーザを自分の都合のいいユーザに変更する事は出来るが、可能な限り標準状態を崩さないようにしている。
カスタマイズや設定変更が多くなると、保守性が乏しくなるしね。

recepg.sh [channel] 60秒録画したTSファイルからEPGをXMLにするスクリプト
#!/bin/sh
recfsusb2n -b $1 60 $1.ts
epgdump $1 $1.ts $1.xml

entry.rb [channel] xmlでの番組情報からredmineへ番組チケット登録するスクリプト
#!/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 IssueCategories < ActiveRecord::Base
end

class CustomValues < ActiveRecord::Base
end

class CustomFields < ActiveRecord::Base
  # 使いたいな
end

$entry_count = 0
$ch_name = ''

#
# チケット登録
#
def entry_issue date, start, duration, title, desc, category, channel
  #  p date, start, duration, title, desc, category, channel
  #  return

  # カテゴリ
  cate = IssueCategories.first( :conditions =>
                                {:project_id => 1, :name => category } )
  cate = IssueCategories.create( :project_id => 1, :name => category ) if cate.nil?

  # マッチするチケットを検索
  if Issue.count( :conditions => { :start_date => date, :subject => title }) > 0
    return #登録済み
  end

  # チケット作成
  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 => title,
                        :description => desc,
                        :start_date => date,
                        :due_date => date,
                        :estimated_hours => duration,
                        :category_id => cate.id )

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

  if issue.save
    # 開始時間
    CustomValues.create( :customized_type => "Issue",
                         :customized_id => issue.id,
                         :custom_field_id => 1, #'開始時間'
                         :value => start )
    # チャンネル
    CustomValues.create( :customized_type => "Issue",
                         :customized_id => issue.id,
                         :custom_field_id => 2, #'チャンネル'
                         :value => channel )
  end

  $entry_count = $entry_count + 1

end # entry_issue

#
# TVプログラム登録
#
def entry_program prog
  start = Time.parse( prog['start'] )
  stop = Time.parse( prog['stop'] )
  duration = (stop - start) / 60

  date = start.to_date
  start = start.strftime("%H%M").to_i

#  channel = prog['channel'].gsub(/ontv.*/,'') + $ch_name
  channel = prog['channel']+ '.'+$ch_name
  category = prog['category'].join(".")
  title = prog['title']
  desc = prog['desc']
  desc = "" if desc.kind_of? Hash

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

  # デスクリプションが無い番組は登録しない
  return if desc == ''

  title.gsub!(/(【二】|【デ】|【S】|【字】)/,"")

  entry_issue date, start, duration, title, desc, category, channel
end

#
# メイン
#
xml_file = ARGV.shift + '.xml'
h = Hash.from_xml( open(xml_file).read )["tv"]

h.each_pair {|key,value|
  if key == 'channel'
    $ch_name = value['display_name']
  elsif key == 'programme'
    value.each {|prog| entry_program prog}
  end
}

puts "%d entried" % $entry_count

ここは結構苦労しましたよ。前記事でも載せたけど、流れで同じものを置きました。

基本この2つのスクリプトを使って自動化を行う。
ジョブ1:recepg.sh [ch1]
ジョブ2:recepg.sh [ch2]
 ジョブ3:entry.rb [ch1]
  ジョブ4:recepg.sh [ch3]

  ジョブ5:entry.rb [ch2]
   ・・・
   ジョブN:entry.rb [chN]

最初のジョブ1のrecepgをトリガーにして処理が終わったら次のチャンネルのrecepgとチケット登録のentry.rbの2つを実行するように順に処理が行われるようにした。
recepg.shとentry.rbを全部1つのジョブにしなかったのは、recepgの録画処理中に並列処理でチケット登録を行う方が短時間で終わるから。

こういう同期処理とか平行処理とかをスクリプトプログラムで書かなくてもJenkinsで設定すればプログラムレスで自動化できるところがいいね。

1つのジョブの処理を単純化する事が出来るし、実行状況もブラウザから分かりやすく管理できる。タスクの見える化が簡単。タスクのログも残るので、失敗しているときの状況も把握しやすい。

早速失敗が・・・
egpdump がコアダンプで失敗することがある。
以前書いたrecfsusb2nのffmpeg対応処理で行うダミーループカウントを4回に増やしてリビルドした。
安定したようだ。

WANアドレス登録処理もJenkinsでやる事にした
Jenkinsが使いやすいので、以前記事にもしたWANアドレス管理サーバーへのコミット処理もJenkinsで行う事にした。crontabの方をコメントアウトして実行しないように変更した。crontabからのメールが溜まらなくなったのがうれしい。
gauth.py (jenkins対応)
#!/usr/bin/python
# -*- coding: utf-8 -*-

from subprocess import *
from os import path

mail = '????@gmail.com'
password = '????'
app = '????'

app_url = 'http://'+app+'.appspot.com/'
cookie_file = '.google_myapp_cookie'
if path.expanduser("~")[:5]=='/home':
    cookie_file = path.expanduser("~")+'/'+cookie_file
#print cookie_file

def login():
    try:
        # API認証キーを取得
        print 'Get Auth-Key...\n'
        auth = Popen(['curl','-f','-s',
                      'https://www.google.com/accounts/ClientLogin',
                      '-d','accountType=HOSTED_OR_GOOGLE',
                      '-d','Email='  + mail,
                      '-d','Passwd=' + password,
                      '-d','source=' + app,
                      '-d','service=ah'],
                     stdout=PIPE).communicate()[0]
        # ログインしてクッキー取得
        print 'Login...\n'
        login = Popen(['curl','-c','-',
                       app_url+'_ah/login?auth='+auth[ auth.rindex("Auth=")+5:-1]],
                      stdout=PIPE).communicate()[0]
        cookie = login[login.rindex("ACSID")+6:-1]

        # クッキーの保存
        f = open(cookie_file,'w')
        f.write(cookie)
        f.close()
        return cookie
    except:
        return ""

def get(controller):
    for i in range(2):
        try:
            f = open(cookie_file)
            cookie = f.read()
            f.close()
            ret = Popen(['curl',app_url+controller,'-b','ACSID='+cookie],
                        stdout=PIPE).communicate()[0]
            if ret.find('COMMITED')>0:
                return 0
            else:
                raise
        except:
            cookie = login()
    raise RuntimeError('Login Failure')


jenkinsに失敗が伝わるようにログイン失敗したらpythonも失敗するようにgauth.pyを変更した。
jenkinsから実行された場合cookieファイルがjenkinsのワークスペースに保存されるように変更。

さて、これで日々の番組がredmineチケットに自動登録されるところまでが出来た。
次は録画予約かな。

RedmineとActiveRecordで番組チケット作成

プランとしてはこんな感じで
  1. 各局のEPGデータ取得のため60秒ずつ録画
  2. egpdumpでxmlに変換
  3. ActiveRecordでxmlからredmineチケットを作成
EPG取得用TS録画からxml変換は簡単
$ recfsusb2n -b [channel] 60 [channel].ts
$ epgdump [channel].ts [channel].xml
こんなで十分。これを必要なチャンネル分実行すればいい。

チケット(Issue)作成
ここが試行錯誤した部分。前記事でも書いたがActiveResourceでやろうと思ったけど思うように進まないので、ActiveRecordでやることにした。


番組データをチケットで表現

  • タイトル=>チケット題名
  • 内容=>チケット内容
  • 録画日=>開始日
  • カテゴリ=>チケットカテゴリ
  • 録画時間長さ=>予定工数(ほんとは時間だけど分のまま扱う)

カスタムフィールド(redmineは至る所にカスタムフィールドを追加できる)

  • 録画開始時間(数値)
  • チャンネル(文字列)
  • ジョブ番号(数値):録画ジョブ管理番号

こんな感じで対応させようと思う。


結果から書くと、こんな感じのスクリプトになった。
#!/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 IssueCategories < ActiveRecord::Base
end

class CustomValues < ActiveRecord::Base
end

class CustomFields < ActiveRecord::Base
  # 使いたいな
end

$entry_count = 0
$ch_name = ''

#
# チケット登録
#
def entry_issue date, start, duration, title, desc, category, channel
  #  p date, start, duration, title, desc, category, channel
  #  return

  # カテゴリ
  cate = IssueCategories.first( :conditions =>
                                {:project_id => 1, :name => category } )
  cate = IssueCategories.create( :project_id => 1, :name => category ) if cate.nil?

  # マッチするチケットを検索
  if Issue.count( :conditions => { :start_date => date, :subject => title }) > 0
    return #登録済み
  end

  # チケット作成
  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 => title,
                        :description => desc,
                        :start_date => date,
                        :due_date => date,
                        :estimated_hours => duration,
                        :category_id => cate.id )

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

  if issue.save
    # 開始時間
    CustomValues.create( :customized_type => "Issue",
                         :customized_id => issue.id,
                         :custom_field_id => 1, #'開始時間'
                         :value => start )
    # チャンネル
    CustomValues.create( :customized_type => "Issue",
                         :customized_id => issue.id,
                         :custom_field_id => 2, #'チャンネル'
                         :value => channel )
  end

  $entry_count = $entry_count + 1

end # entry_issue

#
# TVプログラム登録
#
def entry_program prog
  start = Time.parse( prog['start'] )
  stop = Time.parse( prog['stop'] )
  duration = (stop - start) / 60

  date = start.to_date
  start = start.strftime("%H%M").to_i

#  channel = prog['channel'].gsub(/ontv.*/,'') + $ch_name
  channel = prog['channel']+ '.'+$ch_name
  category = prog['category'].join(".")
  title = prog['title']
  desc = prog['desc']
  desc = "" if desc.kind_of? Hash

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

  # デスクリプションが無い番組は登録しない
  return if desc == ''

  title.gsub!(/(【二】|【デ】|【S】|【字】)/,"")

  entry_issue date, start, duration, title, desc, category, channel
end

#
# メイン
#
xml_file = ARGV.shift + '.xml'
h = Hash.from_xml( open(xml_file).read )["tv"]

h.each_pair {|key,value|
  if key == 'channel'
    $ch_name = value['display_name']
  elsif key == 'programme'
    value.each {|prog| entry_program prog}
  end
}

puts "%d entried" % $entry_count

xml => hash
xmlからhashへの変換はactive_supportのcore_extにあるHashクラスを使って簡単に変換した。hashになればrubyで簡単にキーワードとデータを扱えるようになる。

entry_program
必要な項目を取り出して、タイトルとかちょっと加工する。
5分以下の短い番組は多分観ないし、それまで全部登録するとチケット数が多すぎないか?と思ったので排除した。
それから、メンテナンスや内容が書かれていない不明番組も排除するようにした。

entry_issue
まずカテゴリをチェックして新規なら登録する。
既に登録済みのチケットか確認する。
未登録なら必要項目をセットしてチケットを作成する。

カスタムフィールドの開始時間とチャンネルもチケットとリンクさせて作成する。
大体こんな手順で番組チケット作成が出来た。

注意点は、チケットアトリビュートのlft、rgt、及び root_id だ。
関連チケットや親子関係を表現するためのRailsテクニックらしいが、省略するとredmineがエラーを起こす。特に、root_idは親がない場合は自分のIDを入れておくものらしい。

便利

redmineをフロントエンドにした事で表示部分を全然作らないで済んだ。
チケット検索機能がもともと強力なので、番組検索が思い通り。
しかも検索内容をカスタムクエリーとして保存できるんで自分なりの分類が簡単。

出来てみると思いのほかマッチしそうなので、このまま進めてみようっと。

ActiveResourceかActiveRecordか

Redmineで番組録画チケット管理するために、まず放送番組を自動でチケット化する処理を書かねばならない。RedmineはRubyOnRailsのシステムなので、RailsのActiveResourceでやればいいんだよねって気軽に考えていた。

require 'rubygems'
require 'active_resource'
class Redmine < ActiveResource::Base
    self.site = 'http://localhost'
    self.user = '[login-id]'
self.password = '[password]'
end
class Project < Redmine
end
class Issue < Redmine
end

これだけでチケット(Issue)レコードへ簡単にアクセスできるようになる。
self.site、self.user、self.passwordはRedmine上のアカウント。普通にアクセスして操作するのと同じ事をスクリプトで出来るってことだね。
SQLコマンドを知らなくても変換してくれる便利機能だなので使わない手はないだろうとやってみたが。

project = Project.find(:all)  プロジェクト一覧取得

エラーだよ
/var/lib/gems/1.9.1/gems/activeresource-3.2.6/lib/active_resource/base.rb:929:in `instantiate_collection': undefined method `collect!' for # (NoMethodError)
        from /var/lib/gems/1.9.1/gems/activeresource-3.2.6/lib/active_resource/base.rb:901:in `find_every'
        from /var/lib/gems/1.9.1/gems/activeresource-3.2.6/lib/active_resource/base.rb:813:in `find'
        from project.rb:24:in `
'

ん?なんじゃこれは?
色々調べてみたが、Railsの不具合でもなければRedmineの不具合でもないらしい。
お互いの整合性がちゃんと取れていないのでは?というのが自分の結論だ。

Collection-resources-and-pagination
こちらの記事を読んで、何となくだけど納得した。

回避策?
IssueもProjectと同じエラーがでるので、Issueで説明すると

app/views/issues/index.api.srb ファイルの最初の
api.array :issues, api_meta(:total_count => @issue_count, :offset => @offset, :limit => @limit) do

api.array :issues do
に変更してすることで、エラーがなくなった。Projectも同じ対処で回避できた。
collect!を作ればいいんだよとか、Hashの問題だとか、色々あったけど・・・


検索〜
  issues = Issue.find(:all, :conditions => {:project_id => 1})
ん?全然検索できない・・・
issues = Issue.find(:all, :params => {:project_id => 1})
ActiveResourceだと:paramsなんだと。


この辺で、もういいや。ActiveResourceは、使わない事にした。

Railsからちゃんと習得していれば問題ないかもしれないけど、redmineがRailsだったから使ってみようと思ったんで、その辺の基礎知識が乏しい。
ネット検索してみると、ActiveResourceより、ActiveRecordの方が情報が豊富だし。
なにより、redmineのモデル部分はActiveRecordで記述されているんで参考にしやすい。

ということで、DB周りの自動処理はActiveRecordでやることにしたよ。


require 'rubygems'
require 'active_record'

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

class Issue < ActiveRecord::Base
end

ActiveRecordだとこんな感じでredmineDBにアクセスできるようになる。
establish_connectionの中は、redmineのconfig/database.ymlに記述したのと同じ内容を書けばいい。

チケット検索は、Issue.find。作成は、Issue.new で簡単に操作できるね。
こっちの方がDBレコードのイメージそのままな感じなんでフィーリングが合ってる。
初心者の私が色々書いてもしょうがないので、この辺の詳しい事は他を参照してね。

まだ初歩的な使い方しか出来ないけど、それでも目的のものは構築できそうだ。
次回からは、構築関係を書いてみる予定。