前言

前幾天 Leko 輾轉偶然知道 Jellyfin 的線上播放器的字幕渲染居然是用 SubtitlesOctopus (目前瀏覽器唯一可用的 libass 相容 ASS 字幕渲染),真是驚為天人。
Leko 馬上架了一個來用,還因此不辭辛勞手動標記了動畫庫內六千多部動畫的 metadata (花了我整整三天,要得腱鞘炎了XDD)

一開始播放影音的時候會發現,奇怪我的中文字幕怎麼都變豆腐?

原來是因為他的 SubtitlesOctopus 預設沒有載入中文字體。

在官方的 Github Issues 內就有解法了 (載入 NotoCJK),但 Leko 覺得還不夠 – 既然都可以讓他載入 NotoCJK 了,那我應該也可以讓他跟一般播放器一樣按需載入需要的字體才對。

沒想到居然滿容易的,不到半小時就解決了,在這裡紀錄一下解法。

2021/6/9 更新: 本篇僅適用於 JellyFin 1.6.4 與其之前版本

前置

  • 字體庫 (推薦 超级字体整合包 X)
  • JellyFin (假設使用 Docker 架設)
  • Windows 環境 (僅在設置時需要)
  • FontLoaderSub (用來產生字體索引)
  • node.js (小腳本我用 Node.js 撰寫,因為很簡單,有能力的讀者可以用其他程式語言實現,或是小小修改後在瀏覽器內執行。)

內文

1. 掛載字幕庫

首先我們將字體庫 bind 到 Jellyfin 內的 /jellyfin/jellyfin-web/libraries/fonts/Pack

2. 產生字幕索引

我們需要一個 {字幕名稱: 字幕檔案名稱} 的 JSON,用來配合接下來步驟解析的 ASS 字幕字體。

首先在字幕庫的根目錄放入 FontLoaderSub 並執行,讓它解析完後會產生一個 fc-subs.db,此即字幕索引

觀察可以得知它是一個 UTF-16LE 的文字檔案,格式是

1
2
3
4
5
6
7
檔案相對路徑
t:字幕格式
v:字幕版本
字幕名稱 1
字幕名稱 2
...
空白行

的循環,我們可以寫一個簡單的腳本來把它變成我們需要的 JSON。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// app.js
const fs = require("fs")

const SUBTITLE_DATABASE = "fc-subs.db"
const OUTPUT = "font.json"

const db = fs.readFileSync(SUBTITLE_DATABASE, 'utf16le').split("\n").map(x => x.slice(0, -1))

const result = {};

/*
0: Font Path
1: t
2: v
-1: Waiting for next block
*/
let stage = 0;
let fontPath = null;
for (const line of db) {
switch (stage) {
// 這裡是因為我只想要 `超级字体整合包X` 目錄底下的字體
// 可以按需修改一下腳本
case 0:
if (!line.startsWith("超级字体整合包X")) {
stage = -1;
fontPath = null;
} else {
stage += 1;
fontPath = line.slice("超级字体整合包X\\".length)
}
continue;
case 1:
case 2:
stage += 1;
continue;
case 3:
if (line.length) {
result[line] = fontPath;
} else {
stage = 0;
}
continue;
case -1:
if (line.length !== 0) continue;
stage = 0;
continue;
}
}

fs.writeFile(OUTPUT, JSON.stringify(result), 'utf8', function (err) {
if (err) {
console.log("An error occured while writing JSON Object to File.");
return console.log(err);
}

console.log("JSON file has been saved.");
});

執行後得到我想要的 font.json,會在第三步驟用到。

3. Plugin.js

Jellyfin 內的 SubtitlesOctopus 主要是在 /jellyfin/jellyfin-web/plugins/htmlVideoPlayer/plugins.js 內 Init 的,我們將他複製出來並 Prettify。

觀察到 renderSsaAss()options 在初始化時有一個 fonts 欄位可以填入 []FontURL,其預設值是

1
2
3
attachments.map(function (i) {
return apiClient.getUrl(i.DeliveryUrl);
})

簡單的觀察就可以得知我們可以用 getTextTrackUrl(track, item) 來拿到 ASS 字幕的 URL。

因此我們寫幾個簡單的函式來解析 ASS 字幕需要的字體並按需把他加入那個 fonts 欄位。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// Leko Font Loading functions
async function _lekoGetFontNames(url) {
const rawAssSubtitle = await (await fetch(url)).text();

let isReachedStyleLine = false
let formatIndex = null;
let result = new Set();
for (const lineRaw of rawAssSubtitle.split("\n")) {
const line = lineRaw.trim();
if (!isReachedStyleLine) {
if (line === "[V4+ Styles]") {
isReachedStyleLine = true;
}
continue;
} else {
if (line.startsWith("Format:")) { // Format Line
formatIndex = line.slice("Format:".length).split(",").findIndex(x => x.includes("Fontname"));
} else if (line.startsWith("Style:")) { // Style Line
const fontName = line.slice("Style:".length).split(",")[formatIndex].trim()
result.add(fontName)
} else if (line.startsWith("[")) { // Style Block Ended
break;
}
}
}
return [...result];
}

var _lekoFontListP = fetch(`${appRouter.baseUrl()}/libraries/fonts/font.json`).then(x => x.json())

async function _lekoFontnameToFilename(fontname) {
const fontList = await _lekoFontListP;
return fontList[fontname];
}

function _lekoGetFontUrl(filename, isBasicFont=false){
if (isBasicFont) {
return `${appRouter.baseUrl()}/libraries/fonts/${filename}`
} else {
return `${appRouter.baseUrl()}/libraries/fonts/Pack/字体/超级字体整合包X/${filename}`
}
}

其中 _lekoFontListPfont.json 路徑可以自己調整 (我是把它掛到 /jellyfin/jellyfin-web/libraries/fonts/font.json)

_lekoGetFontUrl 內的 else 區塊可以看到 fonts/Pack...,那個就是我們第一步驟掛進去的字體。

然後我們應用這些函式 – 在 renderSsaAss() 初始化 var options 前加入:

1
2
3
4
5
6
let _lekoFonts = await _lekoGetFontNames(getTextTrackUrl(track, item));
for (let i = 0; i < _lekoFonts.length; i++) {
_lekoFonts[i] = await _lekoFontnameToFilename(_lekoFonts[i]);
if (_lekoFonts[i]) _lekoFonts[i] = _lekoGetFontUrl(_lekoFonts[i]);
}
_lekoFonts = _lekoFonts.filter(x => x);

然後 optionsfonts 改為

1
2
3
fonts: attachments.map(function (i) {
return apiClient.getUrl(i.DeliveryUrl);
}).concat(_lekoFonts)

也可以在此時順便加入一些預設的 CJK 字體。

最後我們將 function renderSsaAss 改為 async,並在有調用它的函數 await 它並適當的把一些函式改為 async 就大功告成。

最後的 plugin.js 供參。

清除瀏覽器快取後應該就能看到 JellyFin 動態按需載入所需字體了,yay!

file