Write a Niconico Video Player, in Rust
Introduction
Niconicoのフローは以下のような感じ:
- ログイン時取得したCookieを使って、
https://www.nicovideo.jp/watch/so*
をGetする。取得したページにdata-api-data
というJsonデータが埋められている。 - 上記の
data-api-data
を整備して、ほしい映像の解像度情報や認証情報をサーバに送る。するとそれに応じて映像のHLSアドレス(i.e. m3u8)やHeartbeatするためのSession情報が戻ってくる。 - HLSストリーム自体にセッションKeyの認証などないため、基本
ffplay
やMPVなど使えばこのHLSアドレスから映像取れるが、定期的にHeartbeatしないと、サーバ側でセッションを削除するので途中からエラーが出る。ちなみにyoutube-dl
はよく403
エラー起きるのもこれのせい。 - なので、上記HLS再生中、定期的にサーバにHeartbeatのリクエストの投げる。
Get data-api-data
今回は認証の部分に触れないので、(ブラウザで)ニコニコにすでにログインでき、それのCookieを取れている状態を想定している。ちなみに、Cookieを取るにはログインの状態でFirefoxやChromeで適当にページを開くと、デベロッパーツールのネットワークから最初のget
リクエストを見れば、リクエストにヘッダーにCookieがついている。そのCookieをそのままコピーすればよい。
ちなみに、この辺のツールはよくNetscape形式のcookies.txt
を使うが、今回はコピーしたものそのまま使えば良い。
コード書く前に、まずプロジェクト作ろう。
cargo new niconico-video-player
また、Cargo.toml
に以下のPackageを追加しましょう。
[dependencies]
html-escape = "0.2"
reqwest = "0.11"
regex = "1"
serde = "1"
serde_derive = "1"
serde_json = "1"
lazy_static = "1"
clap = "3.0.0-beta.2"
tokio = { version = "1", features = ["full"] }
通信するにはreqwest
、Jsonの解析はserde_json
を使うので、最低限にこの2つが必要。
早速Requestを投げてみる
use reqwest::header::COOKIE;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let cookie = ""; // Your cookie here
let client = reqwest::Client::new();
let res = client.get(arg.url).header(COOKIE, cookie).send().await?.text.await?;
println!("{}",res)
}
header
のcookie
に上で取得したCookieを入れよう。コードに埋め込むのはどうかと思うならばファイルから読み込むかClap
を使ってコマンドラインから取得するのも悪くない。
当然、テストなら最初から自分のアカウントで試す必要もないので、ログインしなくても見れる動画であれば、Cookieなしでも問題ない。
上記実行してみると、無事HTMLファイルを取れるはず。その中で検索してみると、こんな感じの行を見つかるはず:
<div id="js-initial-watch-data" data-api-data="{"ads":null,"category":null,"channel&q ...
この部分はRegexを使えば簡単に取れる、例えば
lazy_static! {
static ref REG: regex::Regex = Regex::new("data-api-data=\"([^\"]+)\"").unwrap();
}
...
match REG.captures(&html) {
Some(data) => {
//取れた中身
},
None => {
panic!("unknown page format")
}
}
ただし見ればわかるように、中身はHTMLのコードになっているので、使う前に一回unescapeが必要だ。幸いhtml-escape
がこの機能提供している:
Some(data) => {
// data[0]は全体の文字列
// data[1]からは各グループ
let escaped_api_data = &data.unwrap()[1];
let mut output = String::new();
let raw_api_data = decode_html_entities_to_string(escaped_api_data, &mut output);
// jsonをパース
let data_api: DataAPI = serde_json::from_str(raw_api_data)?;
},
DataAPI
は色んな情報入ってるが、今回必要なのはmedia
の部分だけ(更に言うと中のsession
さえあれば何とかなる)。なので、その部分をStructとして書き出す:
#[derive(Deserialize)]
pub struct DataAPI {
pub media: Media
}
#[derive(Deserialize)]
pub struct Media {
delivery: Delivery,
}
#[derive(Deserialize)]
pub struct Media {
pub recipeId: String,
pub movie: Movie,
}
#[derive(Deserialize)]
pub struct Movie {
pub contentId: String,
pub audios: Vec<Audio>,
pub videos: Vec<Video>,
pub session: Session,
}
....
実際にページをアクセスしてみて、そのJsonに合わせてひたすら書くだけで良い。ちなみに、data_api.media.delivery.movie.videos
に各StreamのBitrateが書いているので、あとの設定でその中のIDを指定すればその画質で再生できるようになる。
Get HLS URL
次は上記の情報をdata_api.media.delivery.movie.session.urls[0].url
に送信して実際のHLSを取得できるが、その前に送信するデータを整備しないといけない。
送信データはこんな感じ:
{
"session": {
"recipe_id": "",
"content_id": "",
"content_type": "movie",
"content_src_id_sets": [
{
"content_src_ids": [
{
"src_id_to_mux": {
"video_src_ids": [],
"audio_src_ids": []
}
},
]
}
],
"timing_constraint": "",
"keep_method": {
"heartbeat": {
"lifetime": 0
}
},
"protocol": {
"name": "",
"parameters": {
"http_parameters": {
"parameters": {
"hls_parameters": {
"use_well_known_port": "",
"use_ssl": "",
"transfer_preset": "",
"segment_duration": 0
}
}
}
}
},
"content_uri": "",
"session_operation_auth": {
"session_operation_auth_by_signature": {
}
},
"content_auth": {
},
"client_info": {
"player_id": ""
},
"priority": 0
}
}
content_src_id_sets
は取得したいStreamの組み合わせ、video_src_ids
にほしい画質のIDを書けば、サーバ側が指定した画質・音質に応じてストリームを合成して送信してくれる。これ以外の項目はすべてdata_api
に入っているので、そっちを見ながら埋めて良い。
例えば、Rustで生成したいは:
#[derive(Serialize)]
pub struct VideoSessionRequest {
session: RequestVideo,
}
#[derive(Serialize)]
pub struct RequestVideo {
recipe_id: String,
content_id: String,
content_type: String,
content_src_id_sets: Vec<ContentSrcSet>,
timing_constraint: String,
keep_method: KeepMethod,
...
}
あとはこのJsonをサーバに送信するのみ:
let res = client
.post(format!("{}?_format=json",data_api.media.delivery.movie.session.urls[0].url))
.body(serde_json::to_string(&VideoSessionRequest::from(data_api))?)
.send()
.await?
成功すれば、サーバからこんな感じのJsonが貰える:
{
"meta": {
"status": 201,
"message": "created"
},
"data": {
...
}
}
.data.session.content_uri
に実際のアドレスが入っている。
Heartbeat
次は再生が中断されないように定期的にHeartbeatを行う。まずHeartbeatのアドレスはhttps://api.dmc.nico/api/sessions/{}?_format=json&_method=PUT
, {}
に上サーバから貰ったjsonの.data.session.id
。送信する中身は.data
と同じで問題ないので、Rustで書くならこんな感じ:
#[derive(Deserialize)]
pub struct VideoSessionInfo {
pub meta: Meta,
// for heartbeat
pub data: serde_json::Value,
}
送信内容はずっと変わらないので、送信前にまとめて整備すればよい。実際の送信はtokio
でSpawnして、timer
で定期的に行う:
let hb_content = serde_json::to_string(&video_session_info.data)?;
tokio::spawn(async move {
// 2分一回Heratbeat
let mut timer = tokio::time::interval(std::time::Duration::from_secs(120));
let client = reqwest::Client::new();
loop {
timer.tick().await;
let json = client
.post(hb_url)
.body(hb_content.clone())
.send().await.unwrap()
.text().await.unwrap();
}
});
Heartbeatが成功すれば、上記のアドレスをmpv
やffplay
に渡せば普通に再生できるので、tokio
のCommand
を使えば良い:
tokio::process::Command::new("mpv")
.arg(video_session_info.data.session.content_uri)
.spawn()?
.wait()
wait
を使うことで、mpv
などの再生を待つことできるので、Heartbeatもその間ずっと行われる。
これで無事ニコニコの再生をできるようになるはず。さらにやるなら、各リクエストにいい感じのHeader(少なくともUser-agent)をつけたり、コメントを取得してMPVの字幕に変換したりもできるが、今回はとりあえずここまで。