2012/07/29

録画ビデオのpodcast対応をredmineプラグインで

録画ビデオ視聴をredmineプラグインで出来るようにしたので、podcastも対応させる。
普通の録画ビデオフォーマットだとiPodTouch初代だと観れない問題を解決すべく、mpeg4フォーマットに更に変換したものをpodcastで観れるようにするというもので、
以前の記事でepgrecを対応させたが、便利だったのでredmineシステムでもやってみた。

前回は録画したものを何でもかんでもpodcastで出すようにしていたが、今回は録画が終了したものをredmine上で選択したものだけpodcastへ公開する形にした。

録画が正常終了したものはredmineステートで「完了」になるのだけど、新たに「公開」ステートを設けた。マニュアル操作で録画チケットを「公開」にしておくと、次の日にはpodcast上に出てくるという感じ。

そのステート監視と変換処理をするスクリプト
podcast.rb
#!/usr/bin/ruby
# -*- coding: utf-8 -*-
#
require 'rubygems'
require 'active_record'
require 'digest/md5'

@media_path = '/opt/videos/'

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 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
                        )
  end
end

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

# 公開ステータスの録画チケット
Issue.find( :all, :conditions => {:tracker_id => 3,:status_id => 6}).each {|issue|

  t_start = start_date_time issue
  filename = t_start.strftime("%Y%m%d%H%M")
  outfile = filename + '-p.mp4'

  if !FileTest.exists?( @media_path + outfile )
    # MPEG4
    if system '/opt/task/podcast.sh ' + @media_path + filename
      # アタッチ
      attach "MPEG4", issue.id, outfile, "video/m4v"
    end
  end
}
録画で公開ステータスになっているチケットを検索して、podcast用(mpeg4フォーマットのスモールバージョン)ビデオファイルが存在していなければ、作ってチケットにアタッチする処理。
ここでのポイント(微妙だけど・・)は、アタッチするファイルタイプを'video/mp4'ではなく、'video/m4v'にしたことかな。直接クリックして視聴できないんでタイプに意味はないけど、後でこれをキーワードにして検索したりするんで、他のアタッチファイルと区別できるようにしておく。

ipod-touch初代で観れるmpeg4ビデオを作成するシェルスクリプトはこちら
#!/bin/sh
OUT=$1
echo "OUTPUT  : $OUT"
ffmpeg -y -i ${OUT}.mp4 -threads 0 -s 480x272 -c:a copy -c:v mpeg4 -b:v 900k -qmin 3 -qmax 5 -f mp4 ${OUT}-p.mp4
この辺は前記事と変わりはないかな。ファイルの置き場所とかファイル名が違うくらい。

下回りはこんなでいいとして、podcastのGET部分をredmineのプラグインで簡単に組み込んでみた。この記事で書いたプラグインのビューとして作ってみた。

app/controllers/video_controller.rb に’podcast'関数を追加
  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

podcastはrss(xml)だ。xml出力をビューで処理したい。ということで、
render :layout => false; って書いておく。redmineのビューフォーマット全部をなしにしてくれる。

ビューの部分 app/views/video/podcast.html.erb を記述
<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" version="2.0">
  <channel>
    <title>公開ビデオ</title>
    <language>jp</language>
    <itunes:category text="録画">
      <itunes:category text="TV録画"/>
    </itunes:category>

    <% @issues.each do |issue|
       video = issue.attachments.detect {|a| a.content_type == "video/m4v"}

       if video
       channel = issue.custom_values.detect {|v| v.customized_id == issue.id and v.custom_field_id == 2 }
       pubdate = video.filename.sub(/\..*/,'')
       %>
    <item>
      <title><%= issue.subject %></title>
      <enclosure url="http://<%= request.host + path_to_video(video.disk_filename) %>" type="video/mp4"/\
>
      <pubDate><%= pubdate %></pubDate>
      <itunes:author><%= channel.value %></itunes:author>
      <itunes:summary><%= issue.description %></itunes:summary>
      <itunes:category><%= issue.category %></itunes:category>
      <itunes:duration><%= issue.estimated_hours %></itunes:duration>
    </item>
    <% end %>
    <% end %>

  </channel>
</rss>
Railsのviewsになっただけで、以前Smartyテンプレートで記述したのとほとんど同じ。前回は内部処理をPHPで記述していたが、今回はrubyだ。(今回はペースト成功した!)

config/routes.rb に忘れないように以下の1行を追加
get 'video/podcast',:to=>'video#podcast'                                                                       
iTunesからhttp://(redmine-home)/video/podcast をpodcast登録しておく。
前回悩んだguidタグだけど、記述しなくても大丈夫そうだったのでなしにした。

オリンピック録画中なので、あまり大きな変更修正で失敗しない程度の拡張でした。

2012/07/24

video_tagで視聴するredmineプラグインを作る

redmineチケット予約・録画までは出来たが、視聴する部分をどうするか悩んだ結果、
致し方なくじゃなくて、頑張ってredmineプラグインで視聴する部分を作成してみた。

redmineはrailsアプリなので、railsとしてのMVCのVC(View & Controller)を使って簡単に作れるんじゃないかな?と思った。だけどrailsのちゃんとしたプラグインは作ったこと無い。調べながら作って何とかなったので、工程を書き記しておく。

/opt/redmine
ここが自分のredmineのインストール場所。人によって色々だよね。
ここをカレントディレクトリにしてプラグイン生成を行う。

自分のRedmine環境
  Redmine version                          2.0.3.stable
  Ruby version                             1.9.3 (x86_64-linux)
  Rails version                            3.2.6
  Environment                              production
  Database adapter                         Mysql2

プラグイン作成
$ sudo RAILS_ENV=production ruby script/rails generate redmine_plugin redmine_video
RAILS_ENV環境変数を指定しないと
var/lib/gems/1.9.1/gems/bundler-1.1.4/lib/bundler/rubygems_integration.rb:147:in `block in replace_gem': Please install the mysql adapter: `gem install activerecord-mysql-adapter` (mysql is not part of the bundle. Add it to Gemfile.) (LoadError)
とかいうエラーが出る。これを信じるとハマる。

何か昔のRedmineとは作成フレーズが違うのだね。
redmine色が薄れてRails色が濃くなった感じ?
以前はプラグイン名のプレフィックスに勝手に'redmine_'が追加されたような・・・
最新のはそうじゃないみたいだけど、過去の慣習に合わせて'redmine_video'にした。
これも安易な名前だけど、自分用なのでこれでよし。

コントローラ作成
$ sudo RAILS_ENV=production ruby script/rails generate redmine_plugin_controller redmine_video video index
コントローラテンプレートもひとつ作る。アクションは一応'index'だけ作っておく。
追加はコントローラスクリプトに直接書き加えて追加していけばいいか。

/opt/redmine/plugins/redmine_video
プラグインがここに作られた。以前は/opt/redmine/vender/plugins/だったはず。
場所変わった。とにかくここに色々作られるけど、使うのはapp/controllersとapp/viewsだけだろう。

config/routes.rb
これもちゃんと書かないとダメってことになったらしい。勝手には作ってくれないので自分で書き加える。最初は以下の一行だけ書いておく。後からアクションが増えるたびに書き加える。
get 'video/index',:to=>'video#index'
えーと、http://(redmine-url)/video/indexってやったら、videoコントローラのindexアクションがGETメソッドで呼ばれるっていうルート定義。勘違いしていた。アクセスがGETメソッドに限定されるルート定義っていう意味だった。
POST限定なら post、GET/POST両方で使うなら match らしい。

app/controllers/video_controller.rb
この中にvideoコントローラのアクションの内部処理を記述する。
以下のように、indexアクションで録画チケットを検索してViewに渡すようにした。
def index
  @issues =Issue.find(:all, :conditions => {:tracker_id => 3, :status_id => 4}, :order => "start_date DESC")
 end

app/views/video/index.html.erb
コントローラ内でrenderで表示する処理を書かなければ、controller名/アクション名_html.erb
がテンプレートとして表示されるようになる。この中にもrubyスクリプトを埋め込める。試行錯誤の末、出来上がったのがこちら。
<h2>録画ビデオ一覧</h2>

<table class="list">
    <% @issues.each do |issue| %>

    <tr class="<%= cycle('odd','even') %>">
      <td>
        <% thumb = issue.attachments.detect {|a| a.content_type == "image/jpg"} %>
        <%= link_to image_tag("/videos/"+thumb.disk_filename),
            {:action => 'play', :id => issue.id } %>
      </td>
      <td>
        <table>
        <% time = issue.custom_values.detect {|v|
           v.customized_id == issue.id and v.custom_field_id == 1 }
           channel = issue.custom_values.detect {|v|
           v.customized_id == issue.id and v.custom_field_id == 2 } %>
        <tr><th nowrap>タイトル:  <th><b><%= issue.subject %></tr>
        <tr><td nowrap>ID:         <td><%= issue.id %></tr>
        <tr><td nowrap>説明:      <td><%= issue.description %></tr>
        <tr><td nowrap>録画日:    <td><%= issue.start_date %></tr>
        <tr><td nowrap>時間:      <td><%= format("%04d",time.value.to_i) %></tr>
        <tr><td nowrap>チャンネル:<td><%= channel.value %></tr>
        <tr><td nowrap>カテゴリ:  <td><%= issue.category %></tr>
        </table>
      </td>
      <% end %>
</table>
(貼り付けが綺麗にできないので、'<'を"<"にしてます)

<% @issues.each do |issue| %>~<% end %>
コントローラから受け取った@issues分繰り返し。<%と%>で囲ったところがrubyスクリプトとして実行される。何処にでも挿入できる。

<% thumb = issue.attachments.detect {|a| a.content_type == "image/jpg"} %>
サムネイル表示どうしようかと重たけど、チケットにアタッチしてあるやつをこんな風に取得できた。これがそのままattachementクラスなので、thumb.disk_filenameでファイル名が取り出せる。

この辺が外部スクリプトでActiveRecord使ってアクセスしてた時と勝手が違うところだね。

image_tag(filepath)
railsの関数。これ使うと、<img src="filepath" />に展開してくれる。filepathをファイル名だけにすると、"images/filename"に勝手になる。

imagesディレクトリはredmineが使用済みなので、'/videos'でファイルにアクセスできるようにした。

最初は、<img src="/redmine/attachments/<%= thumb.id %>/<%= thumb.filename %>
なんて書いてみたけれど、ものすごく遅いので、/opt/redmine/public/videos/にビデオやサムネイルファイルが保存してある場所へリンクを貼ることにした。
'videos'にしたのは、後で出てくる、video_tagのデフォルトパスになっているから。

link_to
railsの関数。これはアンカータグに変換してくれる。
{:action => 'play', :id => issue.id }
同じコントローラ内のplayアクションにid付きのコマンドへリンクするよってこと。

平たく言うと、サムネイルをクリックすると、video/play/#チケット番号 へ飛ぶリンクが作られるようにってことだ。結果は簡単だけど知らないところからやると結構悩むよ。むずいよ。

開始時間とかのカスタムフィールドもattachmentsと同じ要領で取得できたけど、何かカッコ悪い感じがする。もっと簡素に書けるんじゃないかなぁ。でもRails初心者なのでここまでが精一杯だ。

(redmine-url)/video/index で一覧表示!
録画チケットを、よくあるサムネイル付きの一覧表示させる事ができました。

playアクション
一覧表示のリンクから呼ばれる再生用のビューを作成します。

routes.rbに、get 'video/play/:id',:to=>'video#play' を追加する。
video/play/#id でアクセスしたら、videoコントローラのplayアクションが呼ばれる。
#idは、コントローラスクリプト内でparams[:id]で参照できる。なので、
def play
 @issue = Issue.find(params[:id])
end
っていう簡単な処理をvideoコントローラに追加した。@issueに目的のチケットが格納される。

app/views/video/play.html.erb
index.html.erbと同じ所にplay.html.erbを作る。generateで作らなかったので自分で作る。
<h2>録画ビデオ一覧</h2>
<h2><%= @issue.subject %></h2>

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

<%= link_to video_tag video.disk_filename,
    :poster => image_path("/videos/"+thumb.disk_filename),
:autoplay => true, :autobuffer => true, :controls => true, :size => "960x540" %>
(こっちも、'<'を"<"にしてます)

video_tag
肝心の関数です。video_tagというrails関数を使うと、
video_tag video.filename しか書いてないけど、これで、<video src="/video/filename"> に変換される。アトリビュートもオプション設定で幾つかできるので、適当に設定してみた。

フルスクリーン設定ができない
大体のブラウザでフルスクリーンにできるから大した問題ではないけど、出来ないやつもあるんで、一応大きめのサイズを設定しておく。この辺は別の手段で色々やればなんか出来たりするんじゃないかと思うけど、分からんです。
試しに、:size="100%x100%"とかやってみたけど、やっぱりダメだった・・・

MacやPCだとまあまあ良い感じ
一覧からサムネイルクリックでビデオ再生ページになって自動再生されるように出来た。デスクトップ環境からなら私はこれで十分OKだ。ぜんぜん使える!

だけど、Firefoxだと再生しないぞ?なんででしょう?
何でだ?じゃなくて、Firefoxはogg(theora+vorbis)じゃないと再生できないんだね。最近知りましたよ。そういえばffmpegのコンフィグでそんなのがあったな。これも今後の課題かな。

iPad/Androidなどのモバイル端末だとなぁ
前にも書いた気がするけど、autoplayが効かないのはそのままなので、再生ページで自分で小さな再生ボタンをタッチしなければならない。で、そのページ内で再生されるわけじゃなくビデオプレーヤが起動してその中で再生されるという、まだるっこしい感じになる。

一覧にvideoタグが付いていればいいんじゃね?
と思ってやってみた。一覧のサムネイルにビデオ再生ボタンが付いた感じになった。
心なしかこっちの方が再生ボタンが大きい気がする。モバイル端末ならば、勝手にフルスクリーンで再生されるから、この方が自然な感じがする。
逆に、デスクトップのブラウザだと小さいサムネイルサイズのまま再生されるので、再生ページに飛ばしたほうが自然だ。

うーむ。どっちもどっちだ。ということで、両方ありにした。
視聴する環境で、どっちがいいか自分で選択すればいいじゃないかと。
ということで、選択しやすいメニューを追加することにした。

listアクションとlist.html.erb
コントローラアクション'list'を、indexと同じ処理で追加。
routes.rbへ、get 'video/list',:to=>'video#list' を追加。
index.html.erbのlink_toの部分を以下のように変更しただけのビューを追加。
<%= video_tag video.disk_filename,
       :poster => image_path("/videos/"+thumb.disk_filename),
       :controls => true, :size => "320x180" %>

分かりにくいけど、video#indexがPCに適したやつ。video#listがモバイルに適したやつ。アクションビュー2種類にしたので、この2つを選択するためのメニューを追加する。

アプリケーションメニュー
redmineプラグインメニューはいろんな所に出現させることが出きるようだけど、一番簡単そうでタッチしやすい(モバイル端末だとリンクが小さいと押しにくいのだ)、アプリケーションメニューに追加することにした。

app/init.rb
この中で何処にメニューを出すかとか、パーミッションとかグループとか色々やるらしいけど、アプリケーションメニュー設定は簡単だ。

menu( :application_menu,
      :videos_index, { :controller => 'video', :action => 'index' }, :caption => "VideoIndex" )
menu( :application_menu,
      :videos_view, { :controller => 'video', :action => 'list' }, :caption => "VideoList" )

をdo/endの中に追加すればいいだけ。カッコは省略するほうがRubyOnRailsらしいそうだけど、自分は癖で書いてしまった。

ruby/rails/RailsGuidesをゆっくり和訳してみたよ
プラグイン開発ではこちらのサイトを大いに活用させていただきました。

予想していたよりも遥かに簡素に書けることが分かった。が、そのシンプルな結果を得るまでに苦労する。便利フレームワークは便利になるまでが苦しい。

完成にはまだ至らないけれど、番組データ取得・予約・録画・視聴までの一連のフローは実現できたかな。オリンピックにぎりぎり間に合ってよかった~。

色々実用レベルでの問題を残しっぱなしなので次回からは、そうした細々したところを1つ1つ解決していく感じになるかな。

因みに、不完全ながらもオリンピック番組を録画するために、クエリー使った自動予約機能を使う。重複処理ができてないので、1チャンネルだけにしてかつ、ライブじゃなくて録画番組の方が予約されるように設定した。うまく処理されるかなぁ?

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レコードのイメージそのままな感じなんでフィーリングが合ってる。
初心者の私が色々書いてもしょうがないので、この辺の詳しい事は他を参照してね。

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

2012/07/10

Ubuntu12.04LTSにRedmine2.0.3を入れる

地デジ録画システムとしてRedmineを使ってみる(予定)

wavecastやepgrecと同様の構成ではわざわざ自分でやる意味が無い。と言っても、他に頼らずにガリガリ作る時間もない。既存システムを応用してササッと構築できないかな?って感じで進めてみる。

Redmine
いわゆるプロジェクトタスク管理システムなわけですが、チケット使って録画番組管理できないかな?と思ってるわけです。

自分にとってこれを使ってみる別の理由
  • 日頃仕事で使ってるんで馴染んでる(本職じゃないんで詳しくはないけど)
  • RailsやREST-APIの勉強になるかな
  • チケット以外の機能(Wiki/Forumやファイルとか)を便利に使えるかも
Redmineインストール
今のRedmineバージョンは1.4.xと2.0.xに分かれてます。仕事では安定している1.3.xを使っているけど個人なので最新の2.0.3を入れることに。単に新しいの使ってみたいっていうだけ。
詳しいインストール手順は本家「Redmineのインストールにお任せするとして、ここではポイントだけ書き残しておきます。

ruby1.8.x環境が入っているとじゃまなので、綺麗にパージしておこう。
redmine2.0.3をダウンロードして/opt/redmineに展開(どこでもいいんだけど)。そして、
$ sudo apt-get install ruby1.9.3
$ sudo gem install bundler --no-rdoc --no-ri
$ sudo apt-get install libmysqlclient-dev
$ cd /opt/redmine (インストールした場所に移動)
$ sudo bundle install --without development test postgresql sqlite rmagick
bundleがredmine/Gemfileを読んで必要なパッケージを一気にインストールしてくれる(楽~)

新しすぎるgemパッケージとかが混在してると動かない場合があるんで注意です。
以下が自分のgemパッケージ環境です
actionmailer (3.2.6)
actionpack (3.2.6)
activemodel (3.2.6)
activerecord (3.2.6)
activeresource (3.2.6)
activesupport (3.2.6)
arel (3.0.2)
builder (3.0.0)
bundler (1.1.4)
coderay (1.0.7)
daemon_controller (1.0.0)
erubis (2.7.0)
fastthread (1.0.7)
hike (1.2.1)
i18n (0.6.0)
journey (1.0.4)
json (1.7.3)
mail (2.4.4)
mime-types (1.19)
multi_json (1.3.6)
mysql2 (0.3.11)
net-ldap (0.3.1)
passenger (3.0.13)
polyglot (0.3.3)
prototype-rails (3.2.1)
rack (1.4.1)
rack-cache (1.2)
rack-openid (1.3.1)
rack-ssl (1.3.2)
rack-test (0.6.1)
rails (3.2.6)
railties (3.2.6)
rake (0.9.2.2)
rdoc (3.12)
ruby-openid (2.1.8)
rubyzip (0.9.9)
sprockets (2.4.3, 2.1.3)
thor (0.15.3)
tilt (1.3.3)
treetop (1.4.10)
tzinfo (0.3.33)
sprockets だけ2つのバージョンが入っていますが、2.1.3が入っていればOKなはず。
新しいバージョンの方は、確か後のPassengerを入れた際に入れたような気がする・・・

mysqlのredmineデータベースとアクセスユーザを作成
$ mysql -uroot -p
create database redmine default character set utf8;
grant all on redmine.* to 'redmine'@'localhost' identified by 'redmine';
flush privileges;
exit;

面倒なんで、DB名=ユーザ名=パスワードで作ってしまう。
(仕事だったらちゃんとセキュリティ考えるよ)

conf/database.yml を編集
ruby1.9なんで adapter : mysql2 です。username,passwordはmysqlで作ったやつを設定。

conf/configuration.yml は編集しない。
メールを今回の目的にどう活用できるか思案中なのでまだ用意しない。しなくても動くし。

秘密鍵作成とDBマイグレーション
$ rake generate_secret_token
$ rake db:migrate RAILS_ENV=production
この辺も以前のバージョンに比べたら楽になりました。

$ RAILS_ENV=production rake redmine:load_default_data
でデフォルトデータの登録というステップがありますが、一般的な開発用として使用しないので、読みません。

webrickサーバー起動して動作確認
$ sudo RAILS_ENV=production ruby script/rails server
書き方がドキュメントとちょっと違うかな。でもこれで動く。


Passenger
apache2経由で動かす方が自然というか便利なのでRailsアプリを起動するPassengerモジュールを入れる。
$ apt-get install libapache2-mod-passenger
だと色々古い環境が入ってしまうので、gemで入れることにした。
$ sudo gem install passenger --no-rdoc --no-ri
$ sudo passenger-install-apache2-module
すると親切に色々足りないものを、こうしてインストールしなさいと教えてくれる。
(色々あるけど教えるから安心してっていうメッセージが面白い)
* To install Curl development headers with SSL support:
Please run apt-get install libcurl4-openssl-dev or libcurl4-gnutls-dev, whichever you prefer.
* To install Apache 2 development headers:
Please run apt-get install apache2-prefork-dev as root.
(あとで、preforkじゃなくてworkerにするよ)
* To install Apache Portable Runtime (APR) development headers:
Please run apt-get install libapr1-dev as root.
* To install Apache Portable Runtime Utility (APU) development headers:
Please run apt-get install libaprutil1-dev as root.
みたいな感じで。言うとおりに色々やって
passenger-install-apache2-module を何度か繰り返して環境が整えば、めでたくビルドが開始される

ubuntuのapacheモジュール構成に従って、confとloadファイルを作成する
/etc/apache2/apache2.confに直接書いてもいいだろうけど、ちょっと御行儀よくしてみる。

[/etc/apache2/mods-avilable/passenger.conf]
PassengerRoot /var/lib/gems/1.9.1/gems/passenger-3.0.13
PassengerRuby /usr/bin/ruby1.9.1

(以下の様な最適化設定もあるようだけど、ちゃんと調べてないんで自分はまだ入れない)
# Passengerが追加するHTTPヘッダを削除するための設定(任意)。
#
Header always unset "X-Powered-By"
Header always unset "X-Rack-Cache"
Header always unset "X-Content-Digest"
Header always unset "X-Runtime"
# 必要に応じてPassengerのチューニングのための設定を追加(任意)。
#
PassengerMaxPoolSize 20
PassengerMaxInstancesPerApp 4
PassengerPoolIdleTime 3600
PassengerUseGlobalQueue on
PassengerHighPerformance on
PassengerStatThrottleRate 10
RailsSpawnMethod smart
RailsAppSpawnerIdleTime 86400
RailsFrameworkSpawnerIdleTime 0
[/etc/apache2/mods-avilable/passenger.load]
LoadModule passenger_module /var/lib/gems/1.9.1/gems/passenger-3.0.13/ext/apache2/mod_passenger.so

具体的なパスは自分のビルドされた状態に合わせるべし。

passengerを有効にする
$ sudo a2enmod passenger

ruby1.9.3入れたのに何で1.9.1なんだ?と疑問が残るが今はそういうことらしい。

apache2-mpm-worker
redmineはRonRアプリだし、多分PHP使わないんで、preforkからworkerに切り替え。
$ sudo apt-get install apache2-mpm-worker
ってやるとpreforkとphp5捨てられて、mpm-workerでアパッチが勝手に動く。
みなさんはここから、php5が動くようにするために、色々やるようですが、自分はここまで。

ドキュメントルートをRedmineにする(方法1)
$ sudo cp /etc/apache2/sites-available/default /etc/apache2/sites-available/redmine
デフォルトをコピーして、
DocumentRoot /opt/redmine/public (自分がredmineインストールした場所/public)
と書き換える

$ sudo a2dissite default(デフォルトを外す)
$ sudo a2ensite redmine (redmineを有効にする)

これでファイルの中身を書き換えずに、コマンド操作で切り替えることができて便利。切り替えないけど・・
http://localhost/ でRedmineがドキュメントルートとして稼働する。
今回はこっち。

RailsBaseURIを使う(方法2)
$ sudo ln -s /opt/redmine/public /var/www/redmine
(/var/wwwにredmine/publicへのリンクを貼る)

/etc/apache2/sites-enabled/現在有効なサイトファイルのVirtualHostタグ内に
RailsBaseURI /redmine
って書く。http://localhost/redmine でアクセス出来るようになる。
会社の方では複数のサーバーやアプリが稼働してるのでこっちタイプです。


こんなことを色々やって自分専用の小さいRedmineシステムが稼働。

実際の運用では、ロードするapacheモジュールを最低限にしたり、apache2のworker設定を最適化したり、mysqld設定を最適化したりとSEな作業が延々と続くと思いますが、今必要なことじゃないので次のステップへ。

次は、「番組情報をチケットにする」です。これは大変そうだ。

2012/07/03

Google Driveとgriveとgitリポジトリ

突然「Google-Drive」の話です

サーバーアプリ開発は何処でも出来る。Linux(Ubuntu)、Mac、Windowsどれも使ってるんでクラウドで環境を共有化したくなるのが自然かなと。ということで実際やってみたものの・・・というお話。

Google Drive
DropBoxでも良かったんですが、こちらは妻と別用途で利用中なので怪しいものは入れられないのです。それで、このためにGoogleDriveを利用することにしたわけです。
(GoogleDriveについては、色々親切ページがあるので、そちらを参照してくださいな)

grive
MacとWindowsのクライアントソフトは本家からダウンロードすれば済みましたが、Linux環境がね・・・
で見つけたのが、「grive」です。griveについては、google-drive CLI 'grive'を参考にされるとよいでしょう。自分と同じUbuntu12.04LTSへインストールしておられます。

だけどね・・・
Windows/Macクライアントは今同期して!っていうのが出来ない。行ったり来たりがスピーディに出来ない。まあそういう用途向きじゃないのはわかるけど。逆にgriveは「今!」しかないんで、ついその感覚になってしまう。

バックアップファイル問題
どこでもだいたいEmacsで編集してるんだけれど、Emacsで変更するとバックアップファイルが作られる。というか、今までのものが'~'付きにリネームされるといったほうが正しいか。これのためか、'~'付きのファイルが同期対象になってしまって、最新ファイルが更新されないとか紛失したりとか。ついにはUbuntuからのgriveがパニック起こしたりとか。そうなったら諦めて新しいフォルダで再構築。orz.

Linuxファイルパーミッション問題
Ubuntuでパーミッション設定していても、Windowsで編集して更新してしまうとパーミションが普通のファイルになってしまう。パーミッション情報は保存されないってことなのでしょうね。そういうものなのかな。別のツール使えば大丈夫?

とにかくこのために、#!/usr/bin/pythonって書いてもパーミション設定が保証されないので、スクリプトコマンドではなく、ただのスクリプトファイルとして扱うことにしたわけです。
(最初にこの記事を読まれると、さっぱり意味不明だと思うので、前記事を参照されたし)

griveの操作感覚は自分好みだけど、その他のクライアント環境がイージー過ぎて、思い通りじゃないっす。自分とそういう環境の相性が悪いだけかもしれないけどね。

GoogleDriveはSDKもあるらしいから、不満だったら自分でクライアント作ればいいんだよ。と遠くの方から聞こえてきそうだけど、それはそれ。きっと賢い方が作ってくれているはずさ。自分は世界中の「それ」を探して活用するスキルを高めるのさ。

探してみたところ
Linuxのfuseラッパーとして使えるfuse-google-driveがありました。
https://github.com/jcline/fuse-google-drive

rubyでアクセスするgemパッケージもありますね。
https://github.com/gimite/google-drive-ruby

うーん。どっちも便利だけど開発ソース共有には向かないなぁ。

やっぱりバージョン管理リポジトリでやるか
今回のような目的には、svnやgitでバージョン管理の方が適してる。仕事でなら日頃使っているけど、プライベートだとリポジトリの置き場に困る。
https://sites.google.com/site/repositorylist/ こちらよると、無料で使えるサイトが幾つかあるらしい。
また共有フォルダでイラっときたら、考えようかなと思ったけど、まてよ?
せっかくGoogleDrive使ってるんだったら、そこにリポジトリ構成できないのかな?

「google drive git」で調べてみると、おやおや、できそうじゃないですか。
参照サイトページ
using-google-drive-with-git
how-use-google-drive-or-dropbox-host-your-private-git-repositories
こういう情報が豊富なのが(多少不満があれど)メジャー環境を使うメリット。

手順はだいたいこんな感じ
  • ローカルリポジトリを適当に構築
  • GoogleDriveフォルダ上で共有マスターリポジトリ作成
    • $ git init [repos.dir] --bare
  • ローカルリポジトリへ戻ってリモート設定
    • $ git remote add origin file:///[repos.fullpath]
  • ローカル編集したらコミット ・・・◎
  • 共有リポジトリへプッシュ
  • (Linuxのgriveを実行してサーバー同期)
  • 別環境からのクローン
    • git clone file:///[GoogleDrive.repos.fullpath]
    • git pull
  • 後は◎からと同じ
要はgithubに公開リポジトリ作成するのと大体同じで分散リポジトリでやればいいってことかな。だけどね。grive同期なら即時反映でいけるんだけど、Mac/WindowsのGoogleDriveからだと、マスターリポジトリへプッシュした変更がちゃんとサーバーへ伝わらないっす。

結局
手順が増えた分確実性が増すならいいんだけれど、不確実なところは変わらなかったので、今のところ普通にGoogleDriveのファイル管理のまま使用しております。
もっと、確実性のある即時同期型のシェアフォルダないっすかね。

grive の mac/windows版があれば一番いいんだけれど。