About

看了一个漫画: 听我的电波吧 (波よ聞いてくれ). (确切来说是先看动画, 然后顺带看了一下漫画与真人剧), 不剧透的安利就是: 有趣的灵魂.

这漫画看了就想要去听广播… 但是 Radiko 对地域有一个限制, 别说在日本国外无法听见, 就是在日本国内听其他地区的广播部分也有一定的限制.

这不直接开始开始一手登陆绕过?

虽然已经有一个 jackyzy823/rajiko 项目, 作为 Chrome 的插件来对 radiko 进行绕过. 但是我不用 Chrome, 并且手机是该死的苹果, 所以, 得整一个自己用的小玩具.

目标是这样的:

  1. 先搞懂基本的原理, 这部分将使用 Common Lisp 进行重写;
  2. 核心使用 Swift 或者其他方式移植到 iOS 平台;

基本的登陆原理

省流版: 所谓的登陆其实就是为了得到一个 token, 为此需要进行两个步骤:

  1. auth1: 向服务器提交一个申请, 获得一个 offsetlength 用于在本地的密钥中截取部分并在 auth2 中提交;
  2. auth2: 向服务器提交 auth1 中提取的密钥, 获得一个 token.

是不是看起来很简单? 毕竟有前人的智慧 (前人的逆向).

于是核心的代码如下所示
(defclass rajiko ()
  ((app-version :reader rajiko-app-version)
   (user-id     :reader rajiko-user-id)
   (user-agent  :reader rajiko-user-agent)
   (device      :reader rajiko-device)
   (area        :initarg  :area
		:initform (error "Missing area. ")
		:reader rajiko-area)
   (token       :reader rajiko-token)
   (partial-key :reader rajiko-partial-key)
   (location    :reader rajiko-location)
   (player      :initform nil))
  (:documentation
   "The client of rajiko, with GPS bypassing. "))

(defmethod initialize-instance :after ((rajiko rajiko) &key (dummy nil))
  (with-slots (app-version device user-id user-agent
	       token partial-key area location)
      rajiko
    (let* ((version (first (pick-random +version-alist+)))
	   (sdk        (alist-getf +version-alist+ version :sdk))
	   (build      (pick-random (alist-getf +version-alist+ version :builds)))
	   (model      (pick-random +model-list+)))
      ;; generate random infomation
      (setf app-version (pick-random +app-version-list+)
	    user-id     (gen-random-userid)
	    user-agent  (concat "Dalvik/2.1.0 (Linux; U; Android "
				version "; " model "/" build ")")
	    device      (concat sdk "." model)
	    location    (gen-GPS area)))
      ;; if dummy, will not auth
      (unless dummy
	(auth rajiko))))

(defmethod auth1 ((rajiko rajiko))
  (with-slots (user-agent app-version device user-id token partial-key) rajiko
    (multiple-value-bind (response http-code headers)
	(dex:get "https://radiko.jp/v2/api/auth1"
		 :headers `(("User-Agent"           . ,user-agent)
			    ("X-Radiko-App"         . "aSmartPhone7a")
			    ("X-Radiko-App-Version" . ,app-version)
			    ("X-Radiko-Device"      . ,device)
			    ("X-Radiko-User"        . ,user-id)))
      (let ((offset     (parse-integer (gethash "x-radiko-keyoffset" headers)))
	    (length     (parse-integer (gethash "x-radiko-keylength" headers))))
	(setf token (gethash "x-radiko-authtoken" headers)
	      partial-key (extract-partialkey offset length))
	(values response http-code)))))

(defmethod auth2 ((rajiko rajiko))
  (with-slots (user-agent app-version token device
	       user-id location partial-key)
      rajiko
    (multiple-value-bind (response http-code)
	(dex:get "https://radiko.jp/v2/api/auth2"
		 :headers `(("User-Agent"           . ,user-agent)
			    ("X-Radiko-App"         . "aSmartPhone7a")
			    ("X-Radiko-App-Version" . ,app-version)
			    ("X-Radiko-AuthToken"   . ,token)
			    ("X-Radiko-Device"      . ,device)
			    ("X-Radiko-User"        . ,user-id)
			    ("X-Radiko-Location"    . ,location)
			    ("X-Radiko-Connection"  . "wifi")
			    ("X-Radiko-Partialkey"  . ,partial-key)))
      (values response http-code))))

那么如何播放电台呢? 很简单, 找到电台, 并找到其对应的推流链接 (HLS), 然后在请求的头中加入: X-Radio-AuthToken, 也就是前面要得到的 token 即可. 这里采用 ffplay 对推流链接进行播放, 实现一个简单的电台播放功能:

(defmethod rajiko-play ((rajiko rajiko) (station rajiko-station))
  (with-slots (player) rajiko
    (when player (rajiko-pause rajiko))
    (setf player
	  (uiop:launch-program `("ffplay"
				 "-v" "0"
				 "-headers" ,(concat "X-Radiko-AuthToken: "
						     (rajiko-token rajiko))
				 "-i" ,(rajiko-station-streaming-url
					station (rajiko-area rajiko))
				 "-nodisp")))))

播放部分和推流链接的获取参考的是: radi.sh. 原本的项目是用来下载 (录音) 的, 但是因为用不着, 所以直接变成播放即可. 差不多就是这样:

(defparameter *rajiko*
  (make-rajiko (rajiko-random-area)))

(rajiko-play *rajiko* (rajiko-random-station "zenkoku")) ;; 开始播放电台
;; (rajiko-pause) ;; 停止播放

有点水啊, 因为是两个项目, 所以 areastation 没有做一个比较好的连接, 还需要手动去匹配, 确实是目前的一个坑. 然后也没有进行过抗压测试与特殊情况的全面测试, 不能保证完全能用就是了…

一个好看好用的前端

这部分将使用 ncurses 来实现, 这里是使用 naryl/cl-tui 来写这个前端, 目标是写一个类似于下面这样的一个界面:

┌─Rajiko───────────────────────────────────────────┐
│                                                  │
│Region     [r]: zenkoku                           │
│Station    [s]: JOAK-FM                           │
│Client     [a]: 熊本                              │
│Status [Space]: paused                            │
│                                                  │
└──────────────────────────────────────────────────┘
┌Select Region─────────────────────────────────────┐
│> zenkoku                                         │
│  kyushu                                          │
│  chugoku-shikoku                                 │
│  kinki                                           │
│  chubu                                           │
│                                                  │
└──────────────────────────────────────────────────┘

最终出来的效果我感觉还行, 只是代码写得太烂了.

Port to iPhone and iPad [NOT FINISH YET]

本来打算用 iSH 上的 SBCL 直接运行, 结果发现更新了之后的源里面的 SBCL 没法运行了… 同理, 源里面的 ECL 也不能使用 (运行都有问题… ).

使用 QEMU (为什么不是 i386/alpine 的 docker 呢? 因为用 OrbStack 的 docker, 貌似提供的 QEMU 缺指令无法运行一些命令? ) 模拟 alpine, 然后编译 ECL 和 SBCL 并拷贝到手机上, 仍然有问题:

  • SBCL 仍然无法运行: 应该是缺指令?
  • 用 ECL 会有一个 SSL 证书登陆的 Bug, 在 QEMU 模拟时也遇到过, 但是忘了怎么解决的了…

所以结论就是暂时还用不了.

[update] 2024/9/15

SSL 的 Bug, 呃, 我怀疑是 ca-certificate 这个包的问题, 由于在 QEMU 上跑的版本和 iSH 的版本不同, 所以导致了出现了一些小小的问题.

但是很显然, 如果我不让 QEMU 和 iSH 的版本进行一个对应, 我就必须用源里提供的软件, 因为在 iSH 上编译程序太慢了. (也许 M 系列的芯片会好一些? 但是与我无关就是了)

不过好消息是, 源里面的 ECL 不能使用的原因倒是发现了:

apk add ecl

这个命令装的依赖不全, 只能说完全没法用 ECL 编一些稍大的项目. 正确的安装姿势应该是:

apk add curl curl-dev \
    gmp gmp-dev \
    ncurses ncurses-dev \
    gc-dev gcc g++ make \
    ecl ecl-dev \
    libffi-dev

然后载入 Quicklisp 即可. 不过不得不说, ECL 是真的慢… (虽然 iSH 也有锅, 但是如果是 SBCL 的话就好了… )

(但是编译完了之后发现没有声音输出的能力, 所以就寄了, 感觉估计还得是 Swift 去写原生的程序会更加靠谱一些. )

后记

至少目前电脑上可以用了, 就没有那么多动力去修改了, 之后有时间的话, 会考虑重新写一个更加通用的 TUI 框架, 然后修一下电台和 client 之间的对应关系.

repo: li-yiyang/rajiko.