@danything/arib 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +82 -0
- package/README.md +65 -2
- package/dist/ait.d.ts +97 -0
- package/dist/ait.js +205 -0
- package/dist/aribtext-gaiji.d.ts +25 -0
- package/dist/aribtext-gaiji.js +69 -0
- package/dist/aribtext.d.ts +31 -0
- package/dist/aribtext.js +314 -0
- package/dist/b24-tables.d.ts +50 -0
- package/dist/b24-tables.js +410 -0
- package/dist/b24caption.d.ts +98 -0
- package/dist/b24caption.js +987 -0
- package/dist/bml-protocol.d.ts +143 -0
- package/dist/bml-protocol.js +12 -0
- package/dist/bml.d.ts +87 -0
- package/dist/bml.js +489 -0
- package/dist/bytes.d.ts +20 -0
- package/dist/bytes.js +45 -0
- package/dist/caption-text.d.ts +122 -0
- package/dist/caption-text.js +39 -0
- package/dist/drcs-smooth.d.ts +52 -0
- package/dist/drcs-smooth.js +163 -0
- package/dist/dsmcc.d.ts +92 -0
- package/dist/dsmcc.js +191 -0
- package/dist/eit.d.ts +316 -0
- package/dist/eit.js +708 -0
- package/dist/language.d.ts +7 -0
- package/dist/language.js +20 -0
- package/dist/multipart.d.ts +43 -0
- package/dist/multipart.js +194 -0
- package/dist/pes.d.ts +99 -0
- package/dist/pes.js +231 -0
- package/dist/psi.d.ts +169 -0
- package/dist/psi.js +497 -0
- package/dist/service-filter.d.ts +95 -0
- package/dist/service-filter.js +248 -0
- package/dist/synth-av.d.ts +51 -0
- package/dist/synth-av.js +298 -0
- package/dist/synth-caption.d.ts +38 -0
- package/dist/synth-caption.js +115 -0
- package/dist/synth.d.ts +183 -0
- package/dist/synth.js +508 -0
- package/package.json +30 -4
package/dist/eit.js
ADDED
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EIT を読む。番組表の中身そのもの。
|
|
3
|
+
*
|
|
4
|
+
* **denpa が自分で読む。** 番組表を誰かに聞きに行かないので、**チューナーが
|
|
5
|
+
* 空いているだけ並列に回せる**し、録画で開いているチャンネルのぶんはただで
|
|
6
|
+
* 手に入る ([agent.md](https://github.com/danything/denpa/blob/main/docs/agent.md))。
|
|
7
|
+
*
|
|
8
|
+
* 扱うのは2種類。
|
|
9
|
+
*
|
|
10
|
+
* - **EIT[p/f]** (table_id 0x4E) … いま流れている番組と次の番組。**放送に追従する。**
|
|
11
|
+
* 野球で延びた終了時刻はここに出る
|
|
12
|
+
* - **EIT[schedule]** (table_id 0x50〜0x5F) … 数日先までの番組表
|
|
13
|
+
*
|
|
14
|
+
* どちらも PID 0x0012 に流れてくる。
|
|
15
|
+
*/
|
|
16
|
+
import { decodeAribText } from "./aribtext.js";
|
|
17
|
+
import { ascii, joinBytes, u16 } from "./bytes.js";
|
|
18
|
+
import { DESC_DIGITAL_COPY_CONTROL, descriptors, isCopyNever, PacketStream, SectionAssembler, } from "./psi.js";
|
|
19
|
+
const PID_EIT = 0x0012;
|
|
20
|
+
const TABLE_PF_ACTUAL = 0x4e;
|
|
21
|
+
const SCHEDULE_ACTUAL_MIN = 0x50;
|
|
22
|
+
/** 詳細 (extended) の表はここから。基本と同じ並びがもう一度始まる */
|
|
23
|
+
const SCHEDULE_ACTUAL_EXTENDED = 0x58;
|
|
24
|
+
const SCHEDULE_ACTUAL_MAX = 0x5f;
|
|
25
|
+
/** セグメント1つが受け持つ時間。0:00 から3時間ずつ、1日8つ */
|
|
26
|
+
const SEGMENT_SPAN = 3 * 3600_000;
|
|
27
|
+
/** 1つの table_id が持つセグメントの数 (= 4日ぶん) */
|
|
28
|
+
const SEGMENTS_PER_TABLE = 32;
|
|
29
|
+
const DESC_SHORT_EVENT = 0x4d;
|
|
30
|
+
const DESC_EXTENDED_EVENT = 0x4e;
|
|
31
|
+
const DESC_COMPONENT = 0x50;
|
|
32
|
+
const DESC_CONTENT = 0x54;
|
|
33
|
+
const DESC_AUDIO_COMPONENT = 0xc4;
|
|
34
|
+
/** 自局のものだけ扱う。他局の番組表 (0x4F / 0x60〜0x6F) は選局し直せば取れる */
|
|
35
|
+
function isEitActual(tableId) {
|
|
36
|
+
return (tableId === TABLE_PF_ACTUAL ||
|
|
37
|
+
(tableId >= SCHEDULE_ACTUAL_MIN && tableId <= SCHEDULE_ACTUAL_MAX));
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* 同じネットワークの**ほかの TS の局**の番組表 (EIT[schedule other])。
|
|
41
|
+
* 並びは自局のもの (0x50〜0x5F) と同じで、`OTHER_SHIFT` だけずれている。
|
|
42
|
+
*
|
|
43
|
+
* BS・CS はどの中継にもネットワーク全局ぶんが流れているが、**詳細はほぼ付かない**
|
|
44
|
+
* (実測で BS 45 局のうち2局ぶん。docs/data.md)。いま/次 (0x4F) は読まない
|
|
45
|
+
*/
|
|
46
|
+
const SCHEDULE_OTHER_MIN = 0x60;
|
|
47
|
+
const SCHEDULE_OTHER_MAX = 0x6f;
|
|
48
|
+
const OTHER_SHIFT = SCHEDULE_OTHER_MIN - SCHEDULE_ACTUAL_MIN;
|
|
49
|
+
/** MJD (修正ユリウス日) の基準。1970-01-01 が 40587 */
|
|
50
|
+
const MJD_EPOCH = 40587;
|
|
51
|
+
/** 放送の時刻は日本時間で書かれている */
|
|
52
|
+
const JST_OFFSET = 9 * 3600;
|
|
53
|
+
const bcd = (byte) => (byte >> 4) * 10 + (byte & 0x0f);
|
|
54
|
+
/** TDT / TOT が流れている PID。**放送の現在時刻** */
|
|
55
|
+
export const PID_TIME = 0x0014;
|
|
56
|
+
const TABLE_TDT = 0x70;
|
|
57
|
+
const TABLE_TOT = 0x73;
|
|
58
|
+
/**
|
|
59
|
+
* TDT / TOT のセクションから**放送の実時刻**を読む。別のテーブルなら null。
|
|
60
|
+
*
|
|
61
|
+
* 頭3バイトのあとに MJD + BCD が5バイト。**TOT も同じ並び**で、後ろに
|
|
62
|
+
* 時差の記述子が付くだけなので同じに読める。
|
|
63
|
+
*
|
|
64
|
+
* データ放送 (`bml.ts` の `onTime`) が読み、BML の時計にします
|
|
65
|
+
*/
|
|
66
|
+
export function parseTimeTable(section) {
|
|
67
|
+
if (section[0] !== TABLE_TDT && section[0] !== TABLE_TOT)
|
|
68
|
+
return null;
|
|
69
|
+
return parseMjdTime(section, 3);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* MJD + BCD の5バイトを epoch ms に直す。
|
|
73
|
+
*
|
|
74
|
+
* 全ビットが1なら「未定」。開始時刻が未定の番組は番組表に置けないので null で返す。
|
|
75
|
+
*/
|
|
76
|
+
export function parseMjdTime(data, at) {
|
|
77
|
+
if ([0, 1, 2, 3, 4].every((i) => data[at + i] === 0xff))
|
|
78
|
+
return null;
|
|
79
|
+
const mjd = u16(data, at);
|
|
80
|
+
const seconds = (mjd - MJD_EPOCH) * 86400 +
|
|
81
|
+
bcd(data[at + 2]) * 3600 +
|
|
82
|
+
bcd(data[at + 3]) * 60 +
|
|
83
|
+
bcd(data[at + 4]) -
|
|
84
|
+
JST_OFFSET;
|
|
85
|
+
return seconds * 1000;
|
|
86
|
+
}
|
|
87
|
+
/** BCD 3バイトの尺 (時分秒) を ms に。全ビット1なら「終了未定」 */
|
|
88
|
+
export function parseBcdDuration(data, at) {
|
|
89
|
+
if (data[at] === 0xff && data[at + 1] === 0xff && data[at + 2] === 0xff)
|
|
90
|
+
return null;
|
|
91
|
+
return ((bcd(data[at]) * 3600 + bcd(data[at + 1]) * 60 + bcd(data[at + 2])) *
|
|
92
|
+
1000);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* 映像の解像度。component_type の上位ニブルで決まる。
|
|
96
|
+
*
|
|
97
|
+
* 480i は 0x01〜0x04 (上位ニブル 0)、そこから先は 0xA?=480p, 0xB?=1080i … と並ぶ。
|
|
98
|
+
* 画面の `videoLabel` がこの文字列をそのまま読む。
|
|
99
|
+
*/
|
|
100
|
+
const RESOLUTION = {
|
|
101
|
+
0: "480i",
|
|
102
|
+
8: "4320p",
|
|
103
|
+
9: "2160p",
|
|
104
|
+
10: "480p",
|
|
105
|
+
11: "1080i",
|
|
106
|
+
12: "720p",
|
|
107
|
+
13: "240p",
|
|
108
|
+
14: "1080p",
|
|
109
|
+
15: "180p",
|
|
110
|
+
};
|
|
111
|
+
/** stream_content。1=MPEG-2, 5=H.264, 9=H.265 */
|
|
112
|
+
const VIDEO_CODEC = {
|
|
113
|
+
1: "mpeg2",
|
|
114
|
+
5: "h.264",
|
|
115
|
+
9: "h.265",
|
|
116
|
+
};
|
|
117
|
+
/** audio_component_descriptor の sampling_rate。3ビットの符号 */
|
|
118
|
+
const SAMPLING_RATE = {
|
|
119
|
+
1: 16000,
|
|
120
|
+
2: 22050,
|
|
121
|
+
3: 24000,
|
|
122
|
+
5: 32000,
|
|
123
|
+
6: 44100,
|
|
124
|
+
7: 48000,
|
|
125
|
+
};
|
|
126
|
+
const lang = (data, at) => ascii(data, at, 3).toLowerCase();
|
|
127
|
+
/**
|
|
128
|
+
* 記述子を読んで番組の中身を埋める。
|
|
129
|
+
*
|
|
130
|
+
* **extended_event は分割して送られてくる。** descriptor_number 順に並んだものを
|
|
131
|
+
* 見出しごとに繋ぎ直さないと、長い説明が途中で切れる。見出しが空の項目は
|
|
132
|
+
* 「直前の項目の続き」という意味なので、そこにも繋ぐ。
|
|
133
|
+
*
|
|
134
|
+
* **繋ぐのは復号する前、バイトのうちに。** 部分ごとに復号して文字列を繋いで
|
|
135
|
+
* いた頃は、続きが**別の文字で出て**いました (実機の「音楽制作ずはのづ ねふびどっ」。
|
|
136
|
+
* 正しくは「音楽制作:ONE MUSIC」)。分割点は
|
|
137
|
+
*
|
|
138
|
+
* - **二バイト文字の途中**に来ることがある (片割れだけでは字にならない)
|
|
139
|
+
* - **文字集合の状態を持ち越す** — G0〜G3 の指示も GL/GR の呼び出しも、
|
|
140
|
+
* 続きの側には書き直されない。前の状態のまま読む前提で送られてくる
|
|
141
|
+
*
|
|
142
|
+
* ので、部分だけを単独で復号すると、そこから先が崩れます。
|
|
143
|
+
*/
|
|
144
|
+
function readDescriptors(event, body) {
|
|
145
|
+
/** 見出しごとの生バイト。**全部揃ってから復号する** (Map なので見出しの出てきた順に並ぶ) */
|
|
146
|
+
const pending = new Map();
|
|
147
|
+
let lastHeading = "";
|
|
148
|
+
for (const [tag, data] of descriptors(body)) {
|
|
149
|
+
switch (tag) {
|
|
150
|
+
case DESC_SHORT_EVENT: {
|
|
151
|
+
const nameLength = data[3];
|
|
152
|
+
const textAt = 4 + nameLength;
|
|
153
|
+
event.name = decodeAribText(data.subarray(4, textAt));
|
|
154
|
+
const textLength = data[textAt];
|
|
155
|
+
event.description = decodeAribText(data.subarray(textAt + 1, textAt + 1 + textLength));
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
case DESC_EXTENDED_EVENT: {
|
|
159
|
+
let at = 5;
|
|
160
|
+
const itemsEnd = 5 + data[4];
|
|
161
|
+
while (at < itemsEnd && at < data.length) {
|
|
162
|
+
const headingLength = data[at];
|
|
163
|
+
const heading = decodeAribText(data.subarray(at + 1, at + 1 + headingLength));
|
|
164
|
+
at += 1 + headingLength;
|
|
165
|
+
const textLength = data[at];
|
|
166
|
+
const text = data.subarray(at + 1, at + 1 + textLength);
|
|
167
|
+
at += 1 + textLength;
|
|
168
|
+
// 見出しが空なら前の項目の続き。改行を挟まずそのまま繋ぐ
|
|
169
|
+
const key = heading === "" ? lastHeading : heading;
|
|
170
|
+
if (key === "")
|
|
171
|
+
continue;
|
|
172
|
+
const parts = pending.get(key);
|
|
173
|
+
if (parts === undefined)
|
|
174
|
+
pending.set(key, [text]);
|
|
175
|
+
else
|
|
176
|
+
parts.push(text);
|
|
177
|
+
lastHeading = key;
|
|
178
|
+
}
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
case DESC_CONTENT: {
|
|
182
|
+
for (let at = 0; at + 2 <= data.length; at += 2) {
|
|
183
|
+
event.genres.push({ lv1: data[at] >> 4, lv2: data[at] & 0x0f });
|
|
184
|
+
}
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
case DESC_COMPONENT: {
|
|
188
|
+
const streamContent = data[0] & 0x0f;
|
|
189
|
+
const componentType = data[1];
|
|
190
|
+
event.video = {
|
|
191
|
+
type: VIDEO_CODEC[streamContent] ?? null,
|
|
192
|
+
resolution: RESOLUTION[componentType >> 4] ?? null,
|
|
193
|
+
};
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
/*
|
|
197
|
+
* audio_component_descriptor (ARIB STD-B10 第2部)。
|
|
198
|
+
*
|
|
199
|
+
* [0] reserved(4) stream_content(4)
|
|
200
|
+
* [1] component_type … ステレオ・デュアルモノ…
|
|
201
|
+
* [2] component_tag
|
|
202
|
+
* [3] stream_type
|
|
203
|
+
* [4] simulcast_group_tag
|
|
204
|
+
* [5] 多言語(1) 主音声(1) 品質(2) 標本化周波数(3) reserved(1)
|
|
205
|
+
* [6..8] ISO 639-2
|
|
206
|
+
* [9..11] ISO 639-2 (多言語のときだけ)
|
|
207
|
+
* [残り] text_char … **放送が付けた名前**
|
|
208
|
+
*/
|
|
209
|
+
case DESC_AUDIO_COMPONENT: {
|
|
210
|
+
const flags = data[5];
|
|
211
|
+
const multiLingual = (flags & 0x80) !== 0;
|
|
212
|
+
const langs = [lang(data, 6)];
|
|
213
|
+
if (multiLingual)
|
|
214
|
+
langs.push(lang(data, 9));
|
|
215
|
+
const text = decodeAribText(data.subarray(6 + (multiLingual ? 6 : 3))).trim();
|
|
216
|
+
const samplingRate = SAMPLING_RATE[(flags >> 1) & 0x07];
|
|
217
|
+
event.audios.push({
|
|
218
|
+
componentType: data[1],
|
|
219
|
+
langs,
|
|
220
|
+
// 表に無い符号 (予約) なら付けない
|
|
221
|
+
...(samplingRate === undefined ? {} : { samplingRate }),
|
|
222
|
+
...(text === "" ? {} : { text }),
|
|
223
|
+
main: (flags & 0x40) !== 0,
|
|
224
|
+
});
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
case DESC_DIGITAL_COPY_CONTROL: {
|
|
228
|
+
event.copyNever = isCopyNever(data);
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
default:
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// 全部揃ってから復号する (分割点が二バイト文字や文字集合の途中に来るため)
|
|
236
|
+
for (const [heading, parts] of pending)
|
|
237
|
+
event.extended[heading] = decodeAribText(joinBytes(parts));
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* EIT のセクションを1本読む。自局のもの以外は null。
|
|
241
|
+
*
|
|
242
|
+
* 壊れたセクションは `SectionAssembler` が CRC で落としているので、ここに来るのは
|
|
243
|
+
* 中身が揃っているものだけ。それでも長さの辻褄が合わないことはあるので、
|
|
244
|
+
* **読める番組まで返す**。
|
|
245
|
+
*/
|
|
246
|
+
export function parseEit(section, options = {}) {
|
|
247
|
+
if (section.length < 18)
|
|
248
|
+
return null;
|
|
249
|
+
const raw = section[0];
|
|
250
|
+
/*
|
|
251
|
+
* ほかの TS のものは、**自局の同じ位置の表として読む。** 並びが同じなので、
|
|
252
|
+
* 揃ったかどうかの数え方 (`ScheduleProgress`) をそのまま使える
|
|
253
|
+
*/
|
|
254
|
+
const other = options.other === true &&
|
|
255
|
+
raw >= SCHEDULE_OTHER_MIN &&
|
|
256
|
+
raw <= SCHEDULE_OTHER_MAX;
|
|
257
|
+
if (!other && !isEitActual(raw))
|
|
258
|
+
return null;
|
|
259
|
+
const shift = other ? OTHER_SHIFT : 0;
|
|
260
|
+
const tableId = raw - shift;
|
|
261
|
+
const result = {
|
|
262
|
+
tableId,
|
|
263
|
+
serviceId: u16(section, 3),
|
|
264
|
+
version: (section[5] >> 1) & 0x1f,
|
|
265
|
+
sectionNumber: section[6],
|
|
266
|
+
lastSectionNumber: section[7],
|
|
267
|
+
transportStreamId: u16(section, 8),
|
|
268
|
+
originalNetworkId: u16(section, 10),
|
|
269
|
+
segmentLastSectionNumber: section[12],
|
|
270
|
+
lastTableId: section[13] - shift,
|
|
271
|
+
events: [],
|
|
272
|
+
};
|
|
273
|
+
let at = 14;
|
|
274
|
+
const end = section.length - 4;
|
|
275
|
+
while (at + 12 <= end) {
|
|
276
|
+
const descriptorsLength = u16(section, at + 10) & 0x0fff;
|
|
277
|
+
const body = section.subarray(at + 12, at + 12 + descriptorsLength);
|
|
278
|
+
const event = {
|
|
279
|
+
serviceId: result.serviceId,
|
|
280
|
+
transportStreamId: result.transportStreamId,
|
|
281
|
+
originalNetworkId: result.originalNetworkId,
|
|
282
|
+
eventId: u16(section, at),
|
|
283
|
+
startAt: parseMjdTime(section, at + 2),
|
|
284
|
+
duration: parseBcdDuration(section, at + 7),
|
|
285
|
+
runningStatus: (section[at + 10] >> 5) & 0x07,
|
|
286
|
+
isFree: (section[at + 10] & 0x10) === 0,
|
|
287
|
+
name: "",
|
|
288
|
+
description: "",
|
|
289
|
+
extended: {},
|
|
290
|
+
genres: [],
|
|
291
|
+
audios: [],
|
|
292
|
+
video: null,
|
|
293
|
+
// 記述子が無ければ「禁止ではない」。詳細の表はもとから載せないので「言っていない」
|
|
294
|
+
copyNever: tableId >= SCHEDULE_ACTUAL_EXTENDED ? null : false,
|
|
295
|
+
};
|
|
296
|
+
at += 12 + descriptorsLength;
|
|
297
|
+
if (body.length < descriptorsLength)
|
|
298
|
+
break;
|
|
299
|
+
readDescriptors(event, body);
|
|
300
|
+
result.events.push(event);
|
|
301
|
+
}
|
|
302
|
+
return result;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* どこまで集まったかを数える。
|
|
306
|
+
*
|
|
307
|
+
* **これが無いと「もう十分」が分からない。** 何分開いておくかを決め打ちにすると、
|
|
308
|
+
* 早く揃った局でも待たされ、遅い局は取りこぼす。EIT は自分がどこまであるかを
|
|
309
|
+
* セクションの中で言っているので、それを数えれば足りる。
|
|
310
|
+
*
|
|
311
|
+
* セクションは**8本ずつのセグメント**に分かれていて、セグメントの中で実際に
|
|
312
|
+
* 使われている最後の番号が `segment_last_section_number`。使われていない番号は
|
|
313
|
+
* 永久に来ないので、そこまで揃えばそのセグメントは終わり。
|
|
314
|
+
*
|
|
315
|
+
* **過ぎた時間帯のセグメントは流れてこない** (`stale`)。空でも先頭の1本は必ず
|
|
316
|
+
* 来るものとして待っていた頃は、**どのチャンネルも例外なく上限まで
|
|
317
|
+
* 開きっぱなし**になっていた (実機14チャンネル、揃って離せたのは0本)。
|
|
318
|
+
*/
|
|
319
|
+
export class ScheduleProgress {
|
|
320
|
+
now;
|
|
321
|
+
/** `table_id` → 受け取ったセクション番号 */
|
|
322
|
+
seen = new Map();
|
|
323
|
+
/** `table_id:セグメント先頭` → そのセグメントの最後の番号 */
|
|
324
|
+
segmentLast = new Map();
|
|
325
|
+
/** `table_id` → 最後のセクション番号 */
|
|
326
|
+
lastSection = new Map();
|
|
327
|
+
lastTableId = SCHEDULE_ACTUAL_MIN;
|
|
328
|
+
/**
|
|
329
|
+
* `table_id` → 版。変わったらその表だけ数え直す。
|
|
330
|
+
*
|
|
331
|
+
* **版は `table_id` ごとに別々に振られる。** 番組表の1つの表 (sub_table) は
|
|
332
|
+
* `table_id` + 局 + TS で決まり、`version_number` はその単位で動く。
|
|
333
|
+
* 局に1つだけ持っていた頃は、**基本 (0x50〜) と詳細 (0x58〜) を行き来する
|
|
334
|
+
* たびに版が違って数えたものを全部捨てて**いた。揃ったと分かることが
|
|
335
|
+
* 二度と無いので、どのチャンネルも上限の10分 (`epgChannelTimeout`) まで開きっぱなしになる。
|
|
336
|
+
*/
|
|
337
|
+
version = new Map();
|
|
338
|
+
/**
|
|
339
|
+
* @param now いまの時刻。過ぎた時間帯を判断するのに使う。
|
|
340
|
+
* 時計を跨いで開いていることがあるので、値ではなく関数で受ける
|
|
341
|
+
*/
|
|
342
|
+
constructor(now = Date.now) {
|
|
343
|
+
this.now = now;
|
|
344
|
+
}
|
|
345
|
+
add(section) {
|
|
346
|
+
// p/f は番組表の完成度とは関係ない (いつでも2番組しか無い)
|
|
347
|
+
if (section.tableId < SCHEDULE_ACTUAL_MIN)
|
|
348
|
+
return;
|
|
349
|
+
if (section.version !== this.version.get(section.tableId)) {
|
|
350
|
+
for (const key of [...this.segmentLast.keys()]) {
|
|
351
|
+
if (key.startsWith(`${section.tableId}:`))
|
|
352
|
+
this.segmentLast.delete(key);
|
|
353
|
+
}
|
|
354
|
+
this.seen.delete(section.tableId);
|
|
355
|
+
this.lastSection.delete(section.tableId);
|
|
356
|
+
this.version.set(section.tableId, section.version);
|
|
357
|
+
}
|
|
358
|
+
const numbers = this.seen.get(section.tableId) ?? new Set();
|
|
359
|
+
numbers.add(section.sectionNumber);
|
|
360
|
+
this.seen.set(section.tableId, numbers);
|
|
361
|
+
this.segmentLast.set(`${section.tableId}:${section.sectionNumber & ~7}`, section.segmentLastSectionNumber);
|
|
362
|
+
this.lastSection.set(section.tableId, section.lastSectionNumber);
|
|
363
|
+
this.lastTableId = Math.max(this.lastTableId, section.lastTableId);
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* 使われている table_id を全部揃えられたか。
|
|
367
|
+
*
|
|
368
|
+
* **番号が飛ぶ。** 1つの `table_id` が受け持つのは4日ぶんで、基本は
|
|
369
|
+
* `0x50` から、詳細は `0x58` から始まる。日本の放送は8日ぶんなので、
|
|
370
|
+
* 実際に流れてくるのは `0x50` `0x51` と `0x58` `0x59` あたりだけで、
|
|
371
|
+
* **`0x52`〜`0x57` は永久に来ません**。
|
|
372
|
+
*
|
|
373
|
+
* `0x50` から `last_table_id` まで全部を待っていた頃は、詳細を積んでいる局が
|
|
374
|
+
* **一度も「揃った」にならず**、どのチャンネルも上限の10分 (`epgChannelTimeout`) まで開きっぱなしに
|
|
375
|
+
* なっていました (実機で、詳細の無い BS11 だけが早く閉じていた)。
|
|
376
|
+
*
|
|
377
|
+
* 見かけた表と、`last_table_id` が指す表だけを見ます。
|
|
378
|
+
*/
|
|
379
|
+
get complete() {
|
|
380
|
+
if (this.seen.size === 0)
|
|
381
|
+
return false;
|
|
382
|
+
// いちばん後ろの表は必ず流れてくる。来ていないなら、まだ途中
|
|
383
|
+
if (!this.seen.has(this.lastTableId))
|
|
384
|
+
return false;
|
|
385
|
+
for (const tableId of this.seen.keys()) {
|
|
386
|
+
if (this.holes(tableId).length > 0)
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
return true;
|
|
390
|
+
}
|
|
391
|
+
/** その表でまだ来ていない節。セグメントの先頭すら来ていなければ、先頭の番号を数える */
|
|
392
|
+
holes(tableId) {
|
|
393
|
+
const numbers = this.seen.get(tableId) ?? new Set();
|
|
394
|
+
const last = this.lastSection.get(tableId) ?? 0;
|
|
395
|
+
const holes = [];
|
|
396
|
+
for (let start = 0; start <= last; start += 8) {
|
|
397
|
+
// 待っていないものを「足りない」と言わない
|
|
398
|
+
if (this.stale(tableId, start))
|
|
399
|
+
continue;
|
|
400
|
+
const segmentLast = this.segmentLast.get(`${tableId}:${start}`);
|
|
401
|
+
if (segmentLast === undefined) {
|
|
402
|
+
holes.push(start);
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
for (let n = start; n <= segmentLast; n++)
|
|
406
|
+
if (!numbers.has(n))
|
|
407
|
+
holes.push(n);
|
|
408
|
+
}
|
|
409
|
+
return holes;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* そのセグメントの時間帯がもう過ぎているか。**過ぎたものは待たない。**
|
|
413
|
+
*
|
|
414
|
+
* セグメントは 0:00 から3時間ずつの枠で、`table_id` と節番号から
|
|
415
|
+
* それがいつの枠なのかが決まる — 基本は `0x50` が当日 0:00 から4日ぶん、
|
|
416
|
+
* `0x51` がその次の4日ぶん。詳細 (`0x58`〜) も同じ並びをもう一度たどる。
|
|
417
|
+
* 終わった枠は放送に乗らない (クラスの説明)。そのぶんの番組は、そこが未来だった数日前に取り込んである。
|
|
418
|
+
*/
|
|
419
|
+
stale(tableId, start) {
|
|
420
|
+
const base = tableId < SCHEDULE_ACTUAL_EXTENDED
|
|
421
|
+
? SCHEDULE_ACTUAL_MIN
|
|
422
|
+
: SCHEDULE_ACTUAL_EXTENDED;
|
|
423
|
+
const segment = (tableId - base) * SEGMENTS_PER_TABLE + start / 8;
|
|
424
|
+
const now = this.now();
|
|
425
|
+
const offset = JST_OFFSET * 1000;
|
|
426
|
+
const midnight = Math.floor((now + offset) / 86400_000) * 86400_000 - offset;
|
|
427
|
+
return midnight + (segment + 1) * SEGMENT_SPAN <= now;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* 何が足りないか。**上限まで開いても揃わなかったときに出す。**
|
|
431
|
+
*
|
|
432
|
+
* 揃ったかどうかしか分からなかった頃は、上限まで開きっぱなしになっている局を
|
|
433
|
+
* 見つけても**その先を追えなかった** — 電波が弱くて欠けているのか、
|
|
434
|
+
* こちらが来ない表を待っているのかで、直し方がまるで違う。
|
|
435
|
+
*/
|
|
436
|
+
report() {
|
|
437
|
+
if (this.seen.size === 0)
|
|
438
|
+
return "EIT[schedule] が1つも来ていない";
|
|
439
|
+
if (!this.seen.has(this.lastTableId)) {
|
|
440
|
+
const seen = [...this.seen.keys()].map(hex).join(",");
|
|
441
|
+
return `最後の表 ${hex(this.lastTableId)} が一度も来ない (来たのは ${seen})`;
|
|
442
|
+
}
|
|
443
|
+
const missing = [];
|
|
444
|
+
for (const tableId of this.seen.keys()) {
|
|
445
|
+
const holes = this.holes(tableId);
|
|
446
|
+
if (holes.length > 0) {
|
|
447
|
+
const head = holes.slice(0, 8).join(",");
|
|
448
|
+
missing.push(`${hex(tableId)} の ${head}${holes.length > 8 ? ` ほか${holes.length - 8}` : ""}`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return missing.length === 0
|
|
452
|
+
? "揃っている"
|
|
453
|
+
: `来ていない節: ${missing.join(" / ")}`;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
const hex = (value) => `0x${value.toString(16)}`;
|
|
457
|
+
/**
|
|
458
|
+
* 流れてくる TS を食べて番組を溜める。
|
|
459
|
+
*
|
|
460
|
+
* 局ごとに `ScheduleProgress` を持ち、**その物理チャンネルに入っている局が
|
|
461
|
+
* 全部揃ったら**読むのをやめられる。同じ番組が何度も来る (カルーセルなので) ので、
|
|
462
|
+
* event_id で上書きしていく。
|
|
463
|
+
*/
|
|
464
|
+
export class EpgReader {
|
|
465
|
+
now;
|
|
466
|
+
options;
|
|
467
|
+
sections = new SectionAssembler(PID_EIT);
|
|
468
|
+
packets = new PacketStream();
|
|
469
|
+
progress = new Map();
|
|
470
|
+
/** `serviceId:eventId` → 番組。あとから来たもので上書きする */
|
|
471
|
+
events = new Map();
|
|
472
|
+
/** p/f で「いま流れている」と言われた番組。録画の延長追従はこれを見る */
|
|
473
|
+
present = new Map();
|
|
474
|
+
/** `takeChanged` のあとに増えた・変わった番組 (`serviceId:eventId`) */
|
|
475
|
+
changed = new Set();
|
|
476
|
+
/** 最後に新しい局を見かけた時刻 (`other` のときだけ使う) */
|
|
477
|
+
lastNewService = 0;
|
|
478
|
+
/**
|
|
479
|
+
* @param now いまの時刻。局ごとの `ScheduleProgress` にそのまま渡す
|
|
480
|
+
* @param options.other ほかの TS の局の番組表も読む。ネットワーク全局ぶんを
|
|
481
|
+
* 1つの中継から先に入れるときに使う (`epg-collect.ts` の初回)
|
|
482
|
+
* @param options.settle `other` のとき、新しい局が出てこなくなってから閉じるまで (ms)
|
|
483
|
+
* @param options.expect `other` のとき、来るはずの局 (service_id)。全部見えて揃えば
|
|
484
|
+
* `settle` を待たずに閉じる。受信できない局が混ざっていれば `settle` で閉じる
|
|
485
|
+
*/
|
|
486
|
+
constructor(now = Date.now, options = {}) {
|
|
487
|
+
this.now = now;
|
|
488
|
+
this.options = options;
|
|
489
|
+
}
|
|
490
|
+
/** 任意の長さのバイト列を食わせる。番組が1つでも増えたら true */
|
|
491
|
+
feed(chunk) {
|
|
492
|
+
let added = false;
|
|
493
|
+
for (const packet of this.packets.feed(chunk)) {
|
|
494
|
+
for (const raw of this.sections.feed(packet)) {
|
|
495
|
+
const section = parseEit(raw, this.options);
|
|
496
|
+
if (section === null)
|
|
497
|
+
continue;
|
|
498
|
+
if (section.tableId >= SCHEDULE_ACTUAL_MIN) {
|
|
499
|
+
if (!this.progress.has(section.serviceId))
|
|
500
|
+
this.lastNewService = this.now();
|
|
501
|
+
const progress = this.progress.get(section.serviceId) ??
|
|
502
|
+
new ScheduleProgress(this.now);
|
|
503
|
+
progress.add(section);
|
|
504
|
+
this.progress.set(section.serviceId, progress);
|
|
505
|
+
}
|
|
506
|
+
for (const event of section.events) {
|
|
507
|
+
if (section.tableId === TABLE_PF_ACTUAL &&
|
|
508
|
+
this.onAir(section, event)) {
|
|
509
|
+
this.present.set(event.serviceId, event);
|
|
510
|
+
}
|
|
511
|
+
if (event.startAt === null || event.duration === null)
|
|
512
|
+
continue;
|
|
513
|
+
this.merge(event);
|
|
514
|
+
added = true;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return added;
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* 溜めてあるものと混ぜる。**別の表で来るものを消し合わせない。**
|
|
522
|
+
*
|
|
523
|
+
* 番組表は2つの表に分かれて流れてくる。同じ event_id が両方に載っていて、
|
|
524
|
+
* **それぞれ自分のぶんの記述子しか持っていない**。
|
|
525
|
+
*
|
|
526
|
+
* - **基本** (table_id 0x50〜0x57) … タイトルと短い説明 (short_event_descriptor)
|
|
527
|
+
* - **詳細** (0x58〜0x5F) … 「番組内容」「出演者」など (extended_event_descriptor)
|
|
528
|
+
*
|
|
529
|
+
* あとから来たもので丸ごと置き換えていた頃は、**詳細のほうが後に届いた番組の
|
|
530
|
+
* タイトルが空になっていた**。実機では番組 23,000 件のうち 11,000 件が名無しで、
|
|
531
|
+
* うち 2,700 件は「タイトルは無いのに番組内容だけある」状態 — 詳細に上書きされた
|
|
532
|
+
* 跡そのもの。番組表には「(番組情報なし)」の列が並んでいた。
|
|
533
|
+
*
|
|
534
|
+
* **中身のあるほうを残す。** 書き換え (「[新]」が付く、誤字が直る) は
|
|
535
|
+
* 空でない値で来るので、これまでどおり新しいほうが勝つ。
|
|
536
|
+
*/
|
|
537
|
+
merge(event) {
|
|
538
|
+
const id = `${event.serviceId}:${event.eventId}`;
|
|
539
|
+
this.changed.add(id);
|
|
540
|
+
const old = this.events.get(id);
|
|
541
|
+
if (old === undefined) {
|
|
542
|
+
this.events.set(id, event);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
this.events.set(id, {
|
|
546
|
+
...event,
|
|
547
|
+
name: event.name === "" ? old.name : event.name,
|
|
548
|
+
description: event.description === "" ? old.description : event.description,
|
|
549
|
+
extended: Object.keys(event.extended).length === 0
|
|
550
|
+
? old.extended
|
|
551
|
+
: event.extended,
|
|
552
|
+
genres: event.genres.length === 0 ? old.genres : event.genres,
|
|
553
|
+
audios: event.audios.length === 0 ? old.audios : event.audios,
|
|
554
|
+
video: event.video ?? old.video,
|
|
555
|
+
copyNever: event.copyNever ?? old.copyNever,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* p/f で「いま流れている」と言えるか。
|
|
560
|
+
*
|
|
561
|
+
* **`running_status` を当てにしきれない。** 4 (放送中) を入れてくる局も
|
|
562
|
+
* あれば、**0 (未定義) しか入れてこない局もある** — 実機の NHK は 74 節
|
|
563
|
+
* ぜんぶ 0 だった。4 だけを見ていると、そういう局では延長追従が丸ごと
|
|
564
|
+
* 効かなくなる (docs/agent.md)。
|
|
565
|
+
*
|
|
566
|
+
* そこで 0 のときは**節の番号で決める**。p/f は仕様で
|
|
567
|
+
* **section 0 が現在・section 1 が次**と決まっていて、こちらは局の都合で
|
|
568
|
+
* 変わらない。1 (まもなく) や 2 (停止中) は今までどおり入れない。
|
|
569
|
+
*/
|
|
570
|
+
onAir(section, event) {
|
|
571
|
+
if (event.runningStatus === 4)
|
|
572
|
+
return true;
|
|
573
|
+
return event.runningStatus === 0 && section.sectionNumber === 0;
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* 前に呼んでから増えた・変わった番組。**読みながら少しずつ保存する**ため
|
|
577
|
+
* (`epg-collect.ts`)。揃うまで待ってからまとめて書くと、全局読みの数分のあいだ
|
|
578
|
+
* 画面に何も出ない
|
|
579
|
+
*/
|
|
580
|
+
takeChanged() {
|
|
581
|
+
const out = [...this.changed].flatMap((id) => this.events.get(id) ?? []);
|
|
582
|
+
this.changed.clear();
|
|
583
|
+
return out;
|
|
584
|
+
}
|
|
585
|
+
/** 書けなかったぶんを、次の `takeChanged` でもう一度返す */
|
|
586
|
+
requeue(events) {
|
|
587
|
+
for (const event of events)
|
|
588
|
+
this.changed.add(`${event.serviceId}:${event.eventId}`);
|
|
589
|
+
}
|
|
590
|
+
/** 溜まった番組。開始時刻の順に並べて返す */
|
|
591
|
+
all() {
|
|
592
|
+
return [...this.events.values()].sort((a, b) => (a.startAt ?? 0) - (b.startAt ?? 0));
|
|
593
|
+
}
|
|
594
|
+
/** 番組表を1つでも見かけた局 */
|
|
595
|
+
services() {
|
|
596
|
+
return [...this.progress.keys()];
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* 見かけた局が**全部**揃ったか。
|
|
600
|
+
*
|
|
601
|
+
* 「1局でも揃えば閉じる」にはしない。1つの物理チャンネルには複数の局が
|
|
602
|
+
* 入っていて (地上波なら MX1 と MX2)、先に揃ったほうで閉じると残りが
|
|
603
|
+
* 永久に埋まらない。
|
|
604
|
+
*/
|
|
605
|
+
get complete() {
|
|
606
|
+
if (this.progress.size === 0)
|
|
607
|
+
return false;
|
|
608
|
+
/*
|
|
609
|
+
* ほかの TS の局まで読むときは、**来るはずの局が全部見えるか、しばらく新しい局が
|
|
610
|
+
* 出てこないことも待つ。** 局ごとに流れてくる順はばらばらで、見かけた局が
|
|
611
|
+
* 揃っただけではまだ1度も来ていない局を置いていく。来るはずの局を知っていれば
|
|
612
|
+
* すぐ閉じられる (WebTS.app・Mirakurun と同じ)
|
|
613
|
+
*/
|
|
614
|
+
if (this.options.other === true &&
|
|
615
|
+
!this.sawExpected() &&
|
|
616
|
+
this.now() - this.lastNewService < (this.options.settle ?? 60_000)) {
|
|
617
|
+
return false;
|
|
618
|
+
}
|
|
619
|
+
for (const progress of this.progress.values()) {
|
|
620
|
+
if (!progress.complete)
|
|
621
|
+
return false;
|
|
622
|
+
}
|
|
623
|
+
return true;
|
|
624
|
+
}
|
|
625
|
+
/** 来るはずの局を教わっていて、それが全部見えたか */
|
|
626
|
+
sawExpected() {
|
|
627
|
+
const expect = this.options.expect ?? [];
|
|
628
|
+
return (expect.length > 0 &&
|
|
629
|
+
expect.every((serviceId) => this.progress.has(serviceId)));
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* 揃っていない局と、その理由。**上限まで開いてしまったときに出す。**
|
|
633
|
+
*
|
|
634
|
+
* 1局でも揃わなければチャンネルごと上限まで開き続けるので、
|
|
635
|
+
* 「どの局のせいか」が分からないと直しようがない。
|
|
636
|
+
*/
|
|
637
|
+
shortfall() {
|
|
638
|
+
const out = [];
|
|
639
|
+
for (const [serviceId, progress] of this.progress) {
|
|
640
|
+
if (!progress.complete)
|
|
641
|
+
out.push(`局 ${serviceId}: ${progress.report()}`);
|
|
642
|
+
}
|
|
643
|
+
return out;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* **1局の「いま」と「次」(EIT[p/f]) を追う。** ライブで番組の切り替わりを知るのに使う。
|
|
648
|
+
*
|
|
649
|
+
* 番組表 (schedule) は数日先までの予定で、**延長や繰り下げには追いつかない**
|
|
650
|
+
* (局が送り直してくるのは後になってから)。p/f は放送そのものに追従していて、
|
|
651
|
+
* 野球が延びれば「いま」の尺が延び、終われば「次」が「いま」に繰り上がる。
|
|
652
|
+
*
|
|
653
|
+
* section 0 が「いま」、section 1 が「次」と決まっている (`EpgReader.onAir` と同じ)。
|
|
654
|
+
*
|
|
655
|
+
* **中身で比べる。** 書き換われば version が上がるはずだが、上げずに中身だけ替える
|
|
656
|
+
* 送り手を当てにしないよう、番組の番号・時刻・尺・タイトルで比べる。p/f は1局ぶん数百バイトが
|
|
657
|
+
* 数秒おきに来るだけなので、毎回読んでも軽い
|
|
658
|
+
*/
|
|
659
|
+
export class PresentFollowing {
|
|
660
|
+
serviceId;
|
|
661
|
+
sections = new SectionAssembler(PID_EIT);
|
|
662
|
+
/** section 0 (いま) と 1 (次) で最後に読んだ番組。来ていなければ null */
|
|
663
|
+
held = [null, null];
|
|
664
|
+
/** 中身の印 (`mark`)。同じものが繰り返し来ても変わったと言わない */
|
|
665
|
+
marks = [null, null];
|
|
666
|
+
/** @param serviceId 追う局 (放送が名乗っている service_id) */
|
|
667
|
+
constructor(serviceId) {
|
|
668
|
+
this.serviceId = serviceId;
|
|
669
|
+
}
|
|
670
|
+
/** いま流れている番組。まだ来ていない・放送が空けているなら null */
|
|
671
|
+
get present() {
|
|
672
|
+
return this.held[0];
|
|
673
|
+
}
|
|
674
|
+
/** 次の番組 */
|
|
675
|
+
get following() {
|
|
676
|
+
return this.held[1];
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* 188 バイトのパケットを1つ。**いまか次のどちらかが変わったら true**。
|
|
680
|
+
* 最初に読めたときも true (知らなかったものが分かった)
|
|
681
|
+
*/
|
|
682
|
+
feedPacket(packet) {
|
|
683
|
+
let changed = false;
|
|
684
|
+
for (const raw of this.sections.feed(packet)) {
|
|
685
|
+
// 番組表 (schedule) と他局の p/f は読まずに捨てる。同じ PID に流れてくる
|
|
686
|
+
if (raw[0] !== TABLE_PF_ACTUAL || raw.length < 18)
|
|
687
|
+
continue;
|
|
688
|
+
if (u16(raw, 3) !== this.serviceId)
|
|
689
|
+
continue;
|
|
690
|
+
const section = parseEit(raw);
|
|
691
|
+
if (section === null || section.sectionNumber > 1)
|
|
692
|
+
continue;
|
|
693
|
+
const slot = section.sectionNumber;
|
|
694
|
+
const event = section.events[0] ?? null;
|
|
695
|
+
const mark = event === null ? "" : markOf(event);
|
|
696
|
+
if (this.marks[slot] === mark)
|
|
697
|
+
continue;
|
|
698
|
+
this.marks[slot] = mark;
|
|
699
|
+
this.held[slot] = event;
|
|
700
|
+
changed = true;
|
|
701
|
+
}
|
|
702
|
+
return changed;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
/** 変わったかどうかを見る中身。**番号・時刻・尺・タイトルだけ** — 番組表に写して画面に出るもの */
|
|
706
|
+
function markOf(event) {
|
|
707
|
+
return `${event.eventId}:${event.startAt}:${event.duration}:${event.name}`;
|
|
708
|
+
}
|