Hoes of Tech

Conceptualization of Technology

Write a Niconico Video Player, in Rust

Introduction

Niconicoのフローは以下のような感じ:

Overview

  1. ログイン時取得したCookieを使って、https://www.nicovideo.jp/watch/so*をGetする。取得したページにdata-api-dataというJsonデータが埋められている。
  2. 上記のdata-api-dataを整備して、ほしい映像の解像度情報や認証情報をサーバに送る。するとそれに応じて映像のHLSアドレス(i.e. m3u8)やHeartbeatするためのSession情報が戻ってくる。
  3. HLSストリーム自体にセッションKeyの認証などないため、基本ffplayMPVなど使えばこのHLSアドレスから映像取れるが、定期的にHeartbeatしないと、サーバ側でセッションを削除するので途中からエラーが出る。ちなみにyoutube-dlはよく403エラー起きるのもこれのせい。
  4. なので、上記HLS再生中、定期的にサーバにHeartbeatのリクエストの投げる。

Get data-api-data

今回は認証の部分に触れないので、(ブラウザで)ニコニコにすでにログインでき、それのCookieを取れている状態を想定している。ちなみに、Cookieを取るにはログインの状態でFirefoxやChromeで適当にページを開くと、デベロッパーツールのネットワークから最初のgetリクエストを見れば、リクエストにヘッダーにCookieがついている。その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)
}

headercookieに上で取得したCookieを入れよう。コードに埋め込むのはどうかと思うならばファイルから読み込むかClapを使ってコマンドラインから取得するのも悪くない。 当然、テストなら最初から自分のアカウントで試す必要もないので、ログインしなくても見れる動画であれば、Cookieなしでも問題ない。

上記実行してみると、無事HTMLファイルを取れるはず。その中で検索してみると、こんな感じの行を見つかるはず:

 <div id="js-initial-watch-data" data-api-data="{&quot;ads&quot;:null,&quot;category&quot;:null,&quot;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が成功すれば、上記のアドレスをmpvffplayに渡せば普通に再生できるので、tokioCommandを使えば良い:

tokio::process::Command::new("mpv")
   .arg(video_session_info.data.session.content_uri)
   .spawn()?
   .wait()

waitを使うことで、mpvなどの再生を待つことできるので、Heartbeatもその間ずっと行われる。

これで無事ニコニコの再生をできるようになるはず。さらにやるなら、各リクエストにいい感じのHeader(少なくともUser-agent)をつけたり、コメントを取得してMPVの字幕に変換したりもできるが、今回はとりあえずここまで。