├── .gitignore
├── unavailable
├── unavailable.sh
├── unavailable.lftp
├── mirror.lftp
├── .lftp
├── mirror.sh
├── www
├── api
│ └── .htaccess
└── .htaccess
├── package.json
├── urls.txt
├── channels.json
└── index.js
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
--------------------------------------------------------------------------------
/unavailable:
--------------------------------------------------------------------------------
1 | unavailable
2 |
--------------------------------------------------------------------------------
/unavailable.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | set -e
3 | cd $(dirname $0)
4 | lftp -f unavailable.lftp
5 |
--------------------------------------------------------------------------------
/unavailable.lftp:
--------------------------------------------------------------------------------
1 | source .lftp
2 | open namami.sakura.ne.jp
3 | put unavailable -o www/unavailable
4 | close
5 | quit
6 |
--------------------------------------------------------------------------------
/mirror.lftp:
--------------------------------------------------------------------------------
1 | source .lftp
2 | open namami.sakura.ne.jp
3 | mirror --reverse --delete --parallel=20 www www
4 | close
5 | quit
6 |
--------------------------------------------------------------------------------
/.lftp:
--------------------------------------------------------------------------------
1 | set net:max-retries 1
2 | set ftp:ssl-auth TLS
3 | set ftp:ssl-force true
4 | set ftp:ssl-allow yes
5 | set ftp:ssl-protect-list yes
6 | set ftp:ssl-protect-data yes
7 | set ftp:ssl-protect-fxp yes
8 |
--------------------------------------------------------------------------------
/mirror.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | set -e
3 | cd $(dirname $0)
4 | wget --quiet --tries=10 --waitretry=0.5 --retry-connrefused --output-document=/dev/null $(head -n 1 urls.txt)
5 | wget --quiet --directory-prefix=www --mirror --no-host-directories --input-file=urls.txt
6 | lftp -f mirror.lftp
7 |
--------------------------------------------------------------------------------
/www/api/.htaccess:
--------------------------------------------------------------------------------
1 | ForceType "application/xml; charset=UTF-8"
2 |
3 |
4 | ForceType "text/html; charset=UTF-8"
5 |
6 |
7 | ForceType "text/plain; charset=UTF-8"
8 |
9 |
10 | ForceType "text/plain; charset=UTF-8"
11 |
12 |
--------------------------------------------------------------------------------
/www/.htaccess:
--------------------------------------------------------------------------------
1 | RewriteEngine On
2 |
3 | RewriteCond %{DOCUMENT_ROOT}/unavailable -f
4 | RewriteRule ^.*$ /unavailable [R=503,L]
5 |
6 | RewriteCond %{QUERY_STRING} ^(.+)$
7 | RewriteRule ^(.+)$ $1\%3F%1?
8 |
9 | RewriteRule ^$ /tv
10 | RewriteRule ^api/v2/(.+)$ /api/v2_app/$1 [L]
11 |
12 | ForceType "text/html; charset=UTF-8"
13 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "namami",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "dependencies": {
7 | "ws": "^8.17.0"
8 | },
9 | "devDependencies": {
10 | "pkg": "^5.8.1"
11 | },
12 | "scripts": {
13 | "test": "echo \"Error: no test specified\" && exit 1",
14 | "pkg": "pkg ."
15 | },
16 | "bin": {
17 | "namami": "index.js"
18 | },
19 | "repository": {
20 | "type": "git",
21 | "url": "git+https://github.com/asannou/namami.git"
22 | },
23 | "author": "asannou",
24 | "license": "ISC",
25 | "bugs": {
26 | "url": "https://github.com/asannou/namami/issues"
27 | },
28 | "homepage": "https://github.com/asannou/namami#readme"
29 | }
30 |
--------------------------------------------------------------------------------
/urls.txt:
--------------------------------------------------------------------------------
1 | http://127.0.0.1:8000/tv
2 | http://127.0.0.1:8000/radio
3 | http://127.0.0.1:8000/bs
4 | http://127.0.0.1:8000/api/v2_app/getapplicationversion
5 | http://127.0.0.1:8000/api/v2_app/session.create
6 | http://127.0.0.1:8000/api/v2_app/session.destroy
7 | http://127.0.0.1:8000/api/v2_app/ng.client
8 | http://127.0.0.1:8000/api/v2_app/ng.owner
9 | http://127.0.0.1:8000/api/v2_app/getchannels
10 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk1
11 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk2
12 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk4
13 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk5
14 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk6
15 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk7
16 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk8
17 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk9
18 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk10
19 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk11
20 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk12
21 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk594
22 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk693
23 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk825
24 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk792
25 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk1287
26 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk1440
27 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk3925
28 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk761
29 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk800
30 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk813
31 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk954
32 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk1134
33 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk1197
34 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk1242
35 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk764
36 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk863
37 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk780
38 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk795
39 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk847
40 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk1422
41 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk778
42 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk807
43 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk1053
44 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk1053
45 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk1431
46 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk789
47 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk765
48 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk802
49 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk851
50 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk899
51 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk1008
52 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk1179
53 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk1314
54 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk1143
55 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk558
56 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk1557
57 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk808
58 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk827
59 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk1278
60 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk1413
61 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk101
62 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk103
63 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk141
64 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk151
65 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk161
66 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk171
67 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk181
68 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk191
69 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk192
70 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk193
71 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk200
72 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk201
73 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk202
74 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk211
75 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk222
76 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk231
77 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk234
78 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk236
79 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk238
80 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk241
81 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk242
82 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk243
83 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk244
84 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk245
85 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk251
86 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk252
87 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk255
88 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk256
89 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk258
90 | http://127.0.0.1:8000/api/v2_app/getflv?v=jk910
91 | http://127.0.0.1:8000/api/v2_app/getpostkey
92 |
--------------------------------------------------------------------------------
/channels.json:
--------------------------------------------------------------------------------
1 | {
2 | "jk1": {
3 | "thread_id": 0,
4 | "name": "NHK 総合",
5 | "tags": ["NHK総合"]
6 | },
7 | "jk2": {
8 | "thread_id": 1,
9 | "name": "Eテレ",
10 | "tags": ["NHK_Eテレ"]
11 | },
12 | "jk4": {
13 | "thread_id": 2,
14 | "name": "日本テレビ",
15 | "tags": ["日本テレビ"]
16 | },
17 | "jk5": {
18 | "thread_id": 3,
19 | "name": "テレビ朝日",
20 | "tags": ["テレビ朝日"]
21 | },
22 | "jk6": {
23 | "thread_id": 4,
24 | "name": "TBS テレビ",
25 | "tags": ["TBSテレビ"]
26 | },
27 | "jk7": {
28 | "thread_id": 5,
29 | "name": "テレビ東京",
30 | "tags": ["テレビ東京"]
31 | },
32 | "jk8": {
33 | "thread_id": 6,
34 | "name": "フジテレビ",
35 | "tags": ["フジテレビ"]
36 | },
37 | "jk9": {
38 | "thread_id": 7,
39 | "name": "TOKYO MX",
40 | "tags": ["TOKYO_MX"]
41 | },
42 | "jk10": {
43 | "thread_id": 8,
44 | "name": "テレ玉",
45 | "tags": ["テレ玉"],
46 | "communities": ["co5253063"]
47 | },
48 | "jk11": {
49 | "thread_id": 9,
50 | "name": "tvk",
51 | "tags": ["tvk"],
52 | "communities": ["co5215296"]
53 | },
54 | "jk12": {
55 | "thread_id": 10,
56 | "name": "チバテレビ",
57 | "tags": ["チバテレビ"]
58 | },
59 | "jk594": {
60 | "id": 1594,
61 | "radiko_id": "r1",
62 | "thread_id": 11,
63 | "name": "NHKラジオ第1"
64 | },
65 | "jk693": {
66 | "id": 1693,
67 | "radiko_id": "r2",
68 | "thread_id": 12,
69 | "name": "NHKラジオ第2"
70 | },
71 | "jk825": {
72 | "id": 1825,
73 | "radiko_id": "fm",
74 | "thread_id": 13,
75 | "name": "NHK-FM"
76 | },
77 | "jk792": {
78 | "id": 10792,
79 | "radiko_id": "AIR-G",
80 | "thread_id": 14,
81 | "name": "AIR-G'"
82 | },
83 | "jk1287": {
84 | "id": 11287,
85 | "radiko_id": "HBC",
86 | "thread_id": 15,
87 | "name": "HBCラジオ"
88 | },
89 | "jk1440": {
90 | "id": 11440,
91 | "radiko_id": "STV",
92 | "thread_id": 16,
93 | "name": "STVラジオ"
94 | },
95 | "jk3925": {
96 | "id": 13925,
97 | "radiko_id": "NSB",
98 | "thread_id": 17,
99 | "name": "ラジオNIKKEI第1放送"
100 | },
101 | "jk761": {
102 | "id": 80761,
103 | "radiko_id": "INT",
104 | "thread_id": 18,
105 | "name": "Inter FM"
106 | },
107 | "jk800": {
108 | "id": 80800,
109 | "radiko_id": "FMT",
110 | "thread_id": 19,
111 | "name": "TOKYO FM"
112 | },
113 | "jk813": {
114 | "id": 80813,
115 | "radiko_id": "FMJ",
116 | "thread_id": 20,
117 | "name": "J-WAVE"
118 | },
119 | "jk954": {
120 | "id": 80954,
121 | "radiko_id": "TBS",
122 | "thread_id": 21,
123 | "name": "TBSラジオ"
124 | },
125 | "jk1134": {
126 | "id": 81134,
127 | "radiko_id": "QRR",
128 | "thread_id": 22,
129 | "name": "文化放送"
130 | },
131 | "jk1197": {
132 | "id": 81197,
133 | "radiko_id": "IBS",
134 | "thread_id": 23,
135 | "name": "茨城放送"
136 | },
137 | "jk1242": {
138 | "id": 81242,
139 | "radiko_id": "LFR",
140 | "thread_id": 24,
141 | "name": "ニッポン放送"
142 | },
143 | "jk764": {
144 | "id": 90764,
145 | "radiko_id": "RADIOBERRY",
146 | "thread_id": 25,
147 | "name": "RADIO BERRY"
148 | },
149 | "jk863": {
150 | "id": 100863,
151 | "radiko_id": "FMGUNMA",
152 | "thread_id": 26,
153 | "name": "FMぐんま"
154 | },
155 | "jk780": {
156 | "id": 110780,
157 | "radiko_id": "BAYFM78",
158 | "thread_id": 27,
159 | "name": "bayfm"
160 | },
161 | "jk795": {
162 | "id": 110795,
163 | "radiko_id": "NACK5",
164 | "thread_id": 28,
165 | "name": "NACK5"
166 | },
167 | "jk847": {
168 | "id": 110847,
169 | "radiko_id": "YFM",
170 | "thread_id": 29,
171 | "name": "FMヨコハマ"
172 | },
173 | "jk1422": {
174 | "id": 111422,
175 | "radiko_id": "JORF",
176 | "thread_id": 30,
177 | "name": "ラジオ日本"
178 | },
179 | "jk778": {
180 | "id": 210778,
181 | "radiko_id": "ZIP-FM",
182 | "thread_id": 31,
183 | "name": "ZIP-FM"
184 | },
185 | "jk807": {
186 | "id": 210807,
187 | "radiko_id": "FMAICHI",
188 | "thread_id": 32,
189 | "name": "FM AICHI"
190 | },
191 | "jk1053": {
192 | "id": 211053,
193 | "radiko_id": "CBC",
194 | "thread_id": 33,
195 | "name": "CBCラジオ"
196 | },
197 | "jk1053": {
198 | "id": 211332,
199 | "radiko_id": "TOKAIRADIO",
200 | "thread_id": 34,
201 | "name": "東海ラジオ"
202 | },
203 | "jk1431": {
204 | "id": 211431,
205 | "radiko_id": "GBS",
206 | "thread_id": 35,
207 | "name": "ぎふチャン"
208 | },
209 | "jk789": {
210 | "id": 240789,
211 | "radiko_id": "FMMIE",
212 | "thread_id": 36,
213 | "name": "radio CUBE FM三重"
214 | },
215 | "jk765": {
216 | "id": 250765,
217 | "radiko_id": "CCL",
218 | "thread_id": 37,
219 | "name": "FM COCOLO"
220 | },
221 | "jk802": {
222 | "id": 250802,
223 | "radiko_id": "802",
224 | "thread_id": 38,
225 | "name": "FM802"
226 | },
227 | "jk851": {
228 | "id": 250851,
229 | "radiko_id": "FMO",
230 | "thread_id": 39,
231 | "name": "FM OSAKA"
232 | },
233 | "jk899": {
234 | "id": 250899,
235 | "radiko_id": "KISSFMKOBE",
236 | "thread_id": 40,
237 | "name": "Kiss FM KOBE"
238 | },
239 | "jk1008": {
240 | "id": 251008,
241 | "radiko_id": "ABC",
242 | "thread_id": 41,
243 | "name": "朝日放送"
244 | },
245 | "jk1179": {
246 | "id": 251179,
247 | "radiko_id": "MBS",
248 | "thread_id": 42,
249 | "name": "毎日放送"
250 | },
251 | "jk1314": {
252 | "id": 251314,
253 | "radiko_id": "OBC",
254 | "thread_id": 43,
255 | "name": "ラジオ大阪"
256 | },
257 | "jk1143": {
258 | "id": 261143,
259 | "radiko_id": "KBS",
260 | "thread_id": 44,
261 | "name": "KBS京都"
262 | },
263 | "jk558": {
264 | "id": 280558,
265 | "radiko_id": "CRK",
266 | "thread_id": 45,
267 | "name": "ラジオ関西"
268 | },
269 | "jk1557": {
270 | "id": 301557,
271 | "radiko_id": "WBS",
272 | "thread_id": 46,
273 | "name": "和歌山放送"
274 | },
275 | "jk808": {
276 | "id": 400808,
277 | "radiko_id": "FMFUKUOKA",
278 | "thread_id": 47,
279 | "name": "FM FUKUOKA"
280 | },
281 | "jk827": {
282 | "id": 400827,
283 | "radiko_id": "LOVEFM",
284 | "thread_id": 48,
285 | "name": "Love FM"
286 | },
287 | "jk1278": {
288 | "id": 401278,
289 | "radiko_id": "RKB",
290 | "thread_id": 49,
291 | "name": "RKBラジオ"
292 | },
293 | "jk1413": {
294 | "id": 401413,
295 | "radiko_id": "KBC",
296 | "thread_id": 50,
297 | "name": "九州朝日放送"
298 | },
299 | "jk101": {
300 | "thread_id": 51,
301 | "bs": true,
302 | "name": "NHKBS-1",
303 | "tags": ["NHK_BS1", "NHKBS-1"]
304 | },
305 | "jk103": {
306 | "thread_id": 52,
307 | "bs": true,
308 | "name": "NHK BSプレミアム",
309 | "tags": ["NHK_BSプレミアム", "NHKBSプレミアム", "BSプレミアム"],
310 | "communities": ["co5175227"]
311 | },
312 | "jk141": {
313 | "thread_id": 53,
314 | "bs": true,
315 | "name": "BS 日テレ",
316 | "tags": ["BS_日テレ", "BS日テレ"],
317 | "communities": ["co5175341"]
318 | },
319 | "jk151": {
320 | "thread_id": 54,
321 | "bs": true,
322 | "name": "BS 朝日",
323 | "tags": ["BS_朝日", "BS朝日"],
324 | "communities": ["co5175345"]
325 | },
326 | "jk161": {
327 | "thread_id": 55,
328 | "bs": true,
329 | "name": "BS-TBS",
330 | "tags": ["BS-TBS", "BSTBS"],
331 | "communities": ["co5176119"]
332 | },
333 | "jk171": {
334 | "thread_id": 56,
335 | "bs": true,
336 | "name": "BSジャパン",
337 | "tags": ["BSジャパン", "BSテレ東"],
338 | "communities": ["co5176122"]
339 | },
340 | "jk181": {
341 | "thread_id": 57,
342 | "bs": true,
343 | "name": "BSフジ",
344 | "tags": ["BSフジ"],
345 | "communities": ["co5176125"]
346 | },
347 | "jk191": {
348 | "thread_id": 58,
349 | "bs": true,
350 | "name": "WOWOWプライム",
351 | "tags": ["WOWOW_PRIME"],
352 | "communities": ["co5251972"]
353 | },
354 | "jk192": {
355 | "thread_id": 59,
356 | "bs": true,
357 | "name": "WOWOWライブ",
358 | "tags": ["WOWOW_LIVE"],
359 | "communities": ["co5251976"]
360 | },
361 | "jk193": {
362 | "thread_id": 60,
363 | "bs": true,
364 | "name": "WOWOWシネマ",
365 | "tags": ["WOWOW_CINEMA"],
366 | "communities": ["co5251983"]
367 | },
368 | "jk200": {
369 | "thread_id": 61,
370 | "bs": true,
371 | "name": "スターチャンネル1"
372 | },
373 | "jk201": {
374 | "thread_id": 62,
375 | "bs": true,
376 | "name": "スターチャンネル2"
377 | },
378 | "jk202": {
379 | "thread_id": 63,
380 | "bs": true,
381 | "name": "スターチャンネル3"
382 | },
383 | "jk211": {
384 | "thread_id": 64,
385 | "bs": true,
386 | "name": "BSイレブン",
387 | "tags": ["BS11"]
388 | },
389 | "jk222": {
390 | "thread_id": 65,
391 | "bs": true,
392 | "name": "TwellV",
393 | "tags": ["TwellV", "BS12"],
394 | "communities": ["co5193029"]
395 | },
396 | "jk231": {
397 | "thread_id": 66,
398 | "bs": true,
399 | "name": "放送大学"
400 | },
401 | "jk234": {
402 | "thread_id": 67,
403 | "bs": true,
404 | "name": "BSグリーンチャンネル",
405 | "tags": ["BSグリーンチャンネル", "グリーンチャンネル"],
406 | "communities": ["co5217651"]
407 | },
408 | "jk236": {
409 | "thread_id": 68,
410 | "bs": true,
411 | "name": "BSアニマックス"
412 | },
413 | "jk238": {
414 | "thread_id": 69,
415 | "bs": true,
416 | "name": "FOX bs 238"
417 | },
418 | "jk241": {
419 | "thread_id": 70,
420 | "bs": true,
421 | "name": "BSスカパー!"
422 | },
423 | "jk242": {
424 | "thread_id": 71,
425 | "bs": true,
426 | "name": "J Sports 1"
427 | },
428 | "jk243": {
429 | "thread_id": 72,
430 | "bs": true,
431 | "name": "J Sports 2"
432 | },
433 | "jk244": {
434 | "thread_id": 73,
435 | "bs": true,
436 | "name": "J Sports 3"
437 | },
438 | "jk245": {
439 | "thread_id": 74,
440 | "bs": true,
441 | "name": "J Sports 4"
442 | },
443 | "jk251": {
444 | "thread_id": 75,
445 | "bs": true,
446 | "name": "BS釣りビジョン"
447 | },
448 | "jk252": {
449 | "thread_id": 76,
450 | "bs": true,
451 | "name": "IMAGICA BS",
452 | "communities": ["co5683458"]
453 | },
454 | "jk255": {
455 | "thread_id": 77,
456 | "bs": true,
457 | "name": "BS日本映画専門チャンネル"
458 | },
459 | "jk256": {
460 | "thread_id": 78,
461 | "bs": true,
462 | "name": "ディズニー・チャンネル"
463 | },
464 | "jk258": {
465 | "thread_id": 79,
466 | "bs": true,
467 | "name": "Dlife"
468 | },
469 | "jk260": {
470 | "thread_id": 80,
471 | "bs": true,
472 | "name": "BS松竹東急",
473 | "communities": ["co5682554"]
474 | },
475 | "jk263": {
476 | "thread_id": 81,
477 | "bs": true,
478 | "name": "BSJapanext",
479 | "communities": ["co5682551"]
480 | },
481 | "jk265": {
482 | "thread_id": 82,
483 | "bs": true,
484 | "name": "BSよしもと",
485 | "communities": ["co5682548"]
486 | },
487 | "jk333": {
488 | "thread_id": 83,
489 | "bs": true,
490 | "name": "AT-X",
491 | "tags": ["AT-X"],
492 | "communities": ["co5245469"]
493 | },
494 | "jk910": {
495 | "thread_id": 84,
496 | "bs": true,
497 | "name": "SOLiVE24"
498 | }
499 | }
500 |
501 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const process = require('process');
4 | const { URL, URLSearchParams } = require('url');
5 | const { Resolver } = require('dns').promises;
6 | const http = require('http');
7 | const https = require('https');
8 | const net = require('net');
9 | const { PassThrough, Transform } = require('stream');
10 | const WebSocket = require('ws');
11 | const channels = require('./channels.json');
12 | const { debug } = console;
13 |
14 | const port = process.argv[3] ?? 80;
15 | const ms_port = 2525;
16 | const ms_address = process.argv[2] ?? '127.0.0.1'
17 | const http_port = port;
18 | const force_delay = 20;
19 |
20 | const lives = {};
21 | const threads = {};
22 |
23 | let local_time, server_time;
24 |
25 | function getServerTime() {
26 | if (server_time) {
27 | const elapsed_time = getNow() - local_time;
28 | return Math.round(server_time + elapsed_time);
29 | } else {
30 | return Math.round(getNow());
31 | }
32 | }
33 |
34 | function getNow() {
35 | return Date.now() / 1000;
36 | }
37 |
38 | function getBaseTime(unix_time = getServerTime()) {
39 | const hour = 60 * 60;
40 | const day = hour * 24;
41 | const timezone = hour * 9;
42 | const offset = timezone - (hour * 4);
43 | return Math.floor((unix_time + offset) / day) * day - offset + 1;
44 | }
45 |
46 | function waitSeconds(delay) {
47 | return new Promise((resolve) => setTimeout(resolve, delay * 1000));
48 | }
49 |
50 | async function httpRequest(url, options) {
51 | return new Promise((resolve, reject) => {
52 | const { protocol } = new URL(url);
53 | const module = protocol == 'https:' ? https : http;
54 | const req = module.request(url, {
55 | ...options,
56 | headers: {
57 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
58 | ...options.headers
59 | }
60 | }, resolve);
61 | req.on('error', reject);
62 | req.end();
63 | });
64 | }
65 |
66 | function httpGet(url, options) {
67 | debug('get', url.toString());
68 | return httpRequest(url, { method: 'GET', ...options });
69 | }
70 |
71 | function slurpMessage(message) {
72 | return new Promise((resolve) => {
73 | let data = '';
74 | message.on('data', (chunk) => data += chunk);
75 | message.on('end', () => resolve(data));
76 | });
77 | }
78 |
79 | async function searchLives(tags, status) {
80 | const url = new URL('https://live.nicovideo.jp/search');
81 | url.search = new URLSearchParams({
82 | status: status,
83 | sortOrder: 'recentDesc',
84 | isTagSearch: 'true',
85 | keyword: tags.join(' ')
86 | });
87 | try {
88 | const { searchResult: { programs } } = await getEmbeddedData(url);
89 | return programs[status].map((program) => program.id);
90 | } catch (error) {
91 | return [];
92 | }
93 | }
94 |
95 | async function getEmbeddedData(url) {
96 | const res = await httpGet(url);
97 | const data = await slurpMessage(res);
98 | const re = /id="embedded-data" data-props="([^"]+)/;
99 | const embedded_data = data.match(re);
100 | if (embedded_data) {
101 | const [, data_props] = embedded_data;
102 | return JSON.parse(data_props.replace(/"/g, '"'));
103 | }
104 | }
105 |
106 | function createWebSocket(webSocketUrl, video) {
107 | return new Promise((resolve, reject) => {
108 | const ws = new WebSocket(
109 | webSocketUrl,
110 | [],
111 | { headers: { 'User-Agent': 'Mozilla/5.0' } }
112 | );
113 | let keep_seat;
114 | ws.on('open', () => {
115 | debug('ws:open', ws._socket.remoteAddress);
116 | sendStartWatching(ws);
117 | })
118 | ws.on('message', (data) => {
119 | const message = JSON.parse(data);
120 | //debug('ws:message', ws._socket.remoteAddress, message);
121 | switch (message.type) {
122 | case 'ping': {
123 | ws.send(JSON.stringify({ "type": "pong" }));
124 | break;
125 | }
126 | case 'serverTime': {
127 | local_time = getNow();
128 | const current_ms = new Date(message.data.currentMs);
129 | server_time = current_ms.getTime() / 1000;
130 | break;
131 | }
132 | case 'seat': {
133 | keep_seat = setInterval(
134 | () => ws.send(JSON.stringify({ "type": "keepSeat" })),
135 | message.data.keepIntervalSec * 1000
136 | );
137 | break;
138 | }
139 | case 'room': {
140 | resolve({ room: message, ws });
141 | break;
142 | }
143 | case 'statistics': {
144 | for (const thread of Object.values(threads)) {
145 | for (const live of thread.lives) {
146 | if (live === video) {
147 | thread.viewers = message.data.viewers;
148 | thread.comments = message.data.comments;
149 | }
150 | }
151 | }
152 | break;
153 | }
154 | }
155 | });
156 | ws.on('close', () => {
157 | debug('ws:close', ws._socket.remoteAddress);
158 | clearInterval(keep_seat);
159 | });
160 | ws.on('error', () => {
161 | debug('ws:error', ws._socket.remoteAddress);
162 | reject();
163 | });
164 | });
165 | }
166 |
167 | function sendStartWatching(ws) {
168 | return ws.send(JSON.stringify(
169 | {
170 | "type": "startWatching",
171 | "data": {
172 | "stream": {
173 | "quality": "high",
174 | "protocol": "hls",
175 | "latency": "high",
176 | "chasePlay": false
177 | },
178 | "room": {
179 | "protocol": "webSocket",
180 | "commentable": true
181 | },
182 | "reconnect": false
183 | }
184 | }
185 | ));
186 | }
187 |
188 | function createMessageWebSocket(room, revisionCheckIntervalMs) {
189 | const { data } = room;
190 | const mws = new WebSocket(data.messageServer.uri);
191 | let interval;
192 | mws.on('open', () => {
193 | debug('mws:open', mws._socket.remoteAddress);
194 | mws.send(JSON.stringify(
195 | [
196 | { "ping": { "content": "rs:0" } },
197 | { "ping": { "content": "ps:0" } },
198 | {
199 | "thread": {
200 | "thread": data.threadId,
201 | "version": "20061206",
202 | "user_id": "guest",
203 | "res_from": 0,
204 | "with_global": 1,
205 | "scores": 1,
206 | "nicoru": 0
207 | }
208 | },
209 | { "ping": { "content": "pf:0" } },
210 | { "ping": { "content": "rf:0" } }
211 | ]
212 | ));
213 | interval = setInterval(() => mws.send(''), revisionCheckIntervalMs);
214 | });
215 | mws.on('close', () => {
216 | debug('mws:close', mws._socket.remoteAddress);
217 | clearInterval(interval);
218 | });
219 | mws.on('error', (error) => {
220 | debug('mws:error', mws._socket?.remoteAddress);
221 | throw error;
222 | });
223 | return mws;
224 | }
225 |
226 | function createHttpServer() {
227 | const handlers = {
228 | '': handleIndex,
229 | 'tv': handleIndex,
230 | 'radio': handleIndex,
231 | 'bs': handleIndex,
232 | 'watch': handleWatch,
233 | 'api': {
234 | 'v2_app': {
235 | 'getapplicationversion': handleGetApplicationVersion,
236 | 'session.create': handleSessionCreate,
237 | 'session.destroy': handleSessionDestroy,
238 | 'ng.client': handleNg,
239 | 'ng.owner': handleNg,
240 | 'getchannels': handleGetChannels,
241 | 'getflv': handleGetFlv,
242 | 'getpostkey': handleGetPostkey
243 | },
244 | 'v2': {
245 | 'getchannels': handleGetChannels,
246 | 'getflv': handleGetFlv,
247 | 'getpostkey': handleGetPostkey,
248 | 'getwaybackkey': handleGetWaybackkey
249 | },
250 | 'getpostkey': handleGetPostkey,
251 | 'thread': handleGetThread
252 | }
253 | }
254 | const server = http.createServer(async (req, res) => {
255 | const url = new URL(req.url, `http://${req.headers.host}`);
256 | const data = await slurpMessage(req);
257 | if (data) url.search = data;
258 | //debug('req', res.socket.remoteAddress, req.method, url.toString());
259 | //debug(req.headers, data);
260 | const paths = url.pathname.substring(1).split('/');
261 | const AsyncFunction = (async () => {}).constructor;
262 | let handler = handlers;
263 | for (const path of paths) {
264 | handler = handler[path];
265 | if (typeof handler == 'function') {
266 | if (handler instanceof AsyncFunction) {
267 | await handler({ url, paths, res });
268 | } else {
269 | handler({ url, paths, res });
270 | }
271 | res.end();
272 | return;
273 | } else if (!handler) {
274 | break;
275 | }
276 | }
277 | res.writeHead(404);
278 | res.end();
279 | });
280 | server.listen(port);
281 | debug('listen', port);
282 | return server;
283 | }
284 |
285 | function handleGetApplicationVersion({ res }) {
286 | res.setHeader('Content-Type', 'application/xml; charset=UTF-8');
287 | res.write('\n');
288 | res.write('\n');
289 | res.write(' windows\n');
290 | res.write(' 2012-02-09 14:50:59\n');
291 | res.write(' \n');
292 | res.write(' 1\n');
293 | res.write(' 2\n');
294 | res.write(' 1\n');
295 | res.write(' 1516\n');
296 | res.write(' \n');
297 | res.write('\n');
298 | }
299 |
300 | function handleGetChannels({ res }) {
301 | res.setHeader('Content-Type', 'application/xml; charset=UTF-8');
302 | res.write('\n');
303 | for (const video of Object.keys(channels)) {
304 | const channel = channels[video];
305 | const tagname = channel.bs ? 'bs_channel' :
306 | channel.radiko_id ? 'radio_channel' : 'channel';
307 | const id = channel.id ?? parseInt(video.substring(2));
308 | const thread = getThread(video);
309 | if (!thread) continue;
310 | const last_res = getLastRes(thread);
311 | res.write(`<${tagname}>\n`);
312 | res.write(`${id}\n`);
313 | if (channel.radiko_id) {
314 | res.write(`${channel.radiko_id}\n`);
315 | } else if (!channel.bs) {
316 | res.write(`${id}\n`);
317 | }
318 | res.write(`${channel.name}\n`);
319 | res.write(`\n`);
320 | res.write(`\n`);
321 | res.write(`${thread.id}\n`);
322 | res.write(`${escapeChat(last_res)}\n`);
323 | res.write(`${thread.force}\n`);
324 | res.write(`${thread.viewers}\n`);
325 | res.write(`${thread.comments}\n`);
326 | res.write(`\n`);
327 | res.write(`${tagname}>\n`);
328 | }
329 | res.write('\n');
330 | }
331 |
332 | function getLastRes(thread) {
333 | const recent_res = thread.recent_res.filter(Boolean).reverse();
334 | const last_res = recent_res.map((r) => r.content).join(' ');
335 | if (last_res.length <= 50) return last_res;
336 | return last_res.substring(0, 50) + '...';
337 | }
338 |
339 | async function handleGetFlv({ url, res }) {
340 | const use_ngrok = false;
341 | const video = url.searchParams.get('v');
342 | const channel = channels[video];
343 | const start_time = parseInt(url.searchParams.get('start_time'));
344 | const thread = getThread(video, start_time || undefined);
345 | let params;
346 | if (channel && thread) {
347 | const archive = start_time ? { archive: 1 } : {};
348 | const tunnel = use_ngrok ? await getNgrokTunnel() : null;
349 | params = new URLSearchParams({
350 | done: 'true',
351 | ...archive,
352 | thread_id: thread.id,
353 | ms: tunnel?.address ?? ms_address,
354 | ms_port: tunnel?.port ?? ms_port,
355 | http_port: http_port,
356 | channel_no: parseInt(video.substring(2)),
357 | channel_name: channel.name,
358 | genre_id: 1,
359 | twitter_enabled: 1,
360 | vip_follower_disabled: 0,
361 | twitter_vip_mode_count: 10000,
362 | twitter_hashtag: '#namami',
363 | twitter_api_url: 'http://jk.nicovideo.jp/api/v2/',
364 | base_time: thread.base_time,
365 | open_time: thread.open_time,
366 | start_time: thread.start_time,
367 | end_time: thread.end_time,
368 | user_id: 2525,
369 | is_premium: 0,
370 | nickname: 'namami'
371 | });
372 | } else {
373 | params = new URLSearchParams({
374 | code: 1,
375 | error: 'invalid_thread',
376 | done: 'true',
377 | });
378 | }
379 | res.setHeader('Content-Type', 'text/html; charset=UTF-8');
380 | res.write(params.toString());
381 | }
382 |
383 | function handleGetPostkey({ res }) {
384 | res.setHeader('Content-Type', 'text/plain; charset=UTF-8');
385 | res.write(`postkey=.${getServerTime()}.unavailable2525252525252525`);
386 | }
387 |
388 | function handleGetWaybackkey({ res }) {
389 | res.setHeader('Content-Type', 'text/plain; charset=UTF-8');
390 | res.write(`waybackkey=${getServerTime()}.unavailable2525252525252525`);
391 | }
392 |
393 | function handleSessionCreate({ res }) {
394 | res.setHeader('Content-Type', 'application/xml; charset=UTF-8');
395 | res.write(`\n`);
396 | res.write(`\n`);
397 | res.write(` \n`);
398 | res.write(` 2525\n`);
399 | res.write(` namami@example.com\n`);
400 | res.write(` exist\n`);
401 | res.write(` namami@example.com\n`);
402 | res.write(` namami@example.net\n`);
403 | res.write(` namami\n`);
404 | res.write(` 0\n`);
405 | res.write(` 東京都\n`);
406 | res.write(` 2000-01-01\n`);
407 | res.write(` 男性\n`);
408 | res.write(` Asia/Tokyo\n`);
409 | res.write(` Japan\n`);
410 | res.write(` JP\n`);
411 | res.write(` ja-jp\n`);
412 | res.write(` jp\n`);
413 | res.write(` reminder\n`);
414 | res.write(` answer\n`);
415 | res.write(` \n`);
416 | res.write(` 000000000002000000000000000000000000000001\n`);
417 | res.write(` ${res.socket.remoteAddress}\n`);
418 | res.write(` 2020-12-01T00:00:00+09:00\n`);
419 | res.write(` 2007-01-01T00:00:00+09:00\n`);
420 | res.write(` 2020-12-01T00:00:00+09:00\n`);
421 | res.write(` https://secure-dcdn.cdn.nimg.jp/nicoaccount/usericon/defaults/blank.jpg\n`);
422 | res.write(` 0\n`);
423 | res.write(` \n`);
424 | res.write(` namami2525namami\n`);
425 | res.write(`\n`);
426 | }
427 |
428 | function handleSessionDestroy({ res }) {
429 | res.setHeader('Content-Type', 'application/xml; charset=UTF-8');
430 | res.write(`\n`);
431 | res.write(`\n`);
432 | }
433 |
434 | function handleNg({ res }) {
435 | res.setHeader('Content-Type', 'application/xml; charset=UTF-8');
436 | res.write(`\n`);
437 | res.write(`\n`);
438 | res.write(` 0\n`);
439 | res.write(`\n`);
440 | }
441 |
442 | function handleGetThread({ url, res }) {
443 | const thread = url.searchParams.get('thread');
444 | res.setHeader('Content-Type', 'text/xml');
445 | res.write(``);
446 | res.write(``);
447 | res.write(``);
448 | res.write(``);
449 | }
450 |
451 | function handleIndex({ paths, res }) {
452 | const filters = {
453 | 'tv': (channel) => !channel.radiko_id && !channel.bs,
454 | 'radio': (channel) => channel.radiko_id,
455 | 'bs': (channel) => channel.bs,
456 | };
457 | const filter = (key) => filters[paths[0] || 'tv'](channels[key]);
458 | res.setHeader('Content-Type', 'text/html; charset=UTF-8');
459 | res.setHeader('Cache-Control', 'no-cache');
460 | res.write(``);
461 | res.write(`
`);
462 | res.write(``);
463 | res.write(`- テレビ
`);
464 | res.write(`- ラジオ
`);
465 | res.write(`- BS
`);
466 | res.write(`
`);
467 | res.write(``);
468 | res.write(``);
469 | res.write(`| チャンネル | `);
470 | res.write(`チャンネル名 | `);
471 | res.write(`最新のコメント | `);
472 | res.write(`勢い | `);
473 | res.write(`
`);
474 | for (const video of Object.keys(channels).filter(filter)) {
475 | const no = parseInt(video.substring(2));
476 | const channel = channels[video];
477 | const thread = getThread(video);
478 | if (!thread) continue;
479 | const last_res = getLastRes(thread);
480 | res.write(``);
481 | res.write(`| ${no}ch | `);
482 | res.write(`${channel.name} | `);
483 | res.write(`${escapeChat(last_res)} | `);
484 | res.write(`${thread.force}コメ/分 | `);
485 | res.write(`
`);
486 | }
487 | res.write(`
`);
488 | res.write(``);
489 | res.write(``);
490 | }
491 |
492 | async function handleWatch({ paths, res }) {
493 | const video = paths[1];
494 | const thread = getThread(video);
495 | if (!thread) return;
496 | res.setHeader('Content-Type', 'text/html; charset=UTF-8');
497 | res.setHeader('Cache-Control', 'no-cache');
498 | res.write(``);
499 | res.write(``);
500 | res.write(``);
501 | for (const live_video of thread.lives) {
502 | if (!lives[live_video]) continue;
503 | const url = `https://live.nicovideo.jp/watch/${live_video}`;
504 | res.write(`- `);
505 | res.write(`${url}`);
506 | res.write(`
`);
507 | }
508 | res.write(`
`);
509 | res.write(``);
510 | res.write(``);
511 | }
512 |
513 | function createWebSocketServer(port) {
514 | const server = net.createServer((socket) => {
515 | debug('c:connect', socket.localPort, socket.remoteAddress);
516 | const client = { stream: createClientStream(socket) };
517 | socket.on('data', (data) => {
518 | data = data.toString();
519 | debug('c:data', socket.remoteAddress, data);
520 | const element = parseElement(data);
521 | switch (element.name) {
522 | case 'thread': {
523 | handleThread(socket, client, element);
524 | break;
525 | }
526 | case 'chat': {
527 | break;
528 | }
529 | }
530 | });
531 | socket.on('end', () => {
532 | debug('c:end', socket.remoteAddress);
533 | });
534 | socket.on('close', () => {
535 | debug('c:close', socket.remoteAddress);
536 | client.thread_stream?.unpipe(client.stream).resume();
537 | clearTimeout(client.timeout);
538 | clearInterval(client.counter);
539 | });
540 | socket.on('error', (err) => {
541 | debug('c:error', socket.remoteAddress, err);
542 | });
543 | client.timeout = setTimeout(() => {
544 | debug('c:timeout', socket.remoteAddress);
545 | client.leave?.call();
546 | socket.destroy();
547 | }, getNextBaseTimeDelay() * 1000);
548 | });
549 | server.listen(port);
550 | debug('s:listen', port);
551 | return server;
552 | }
553 |
554 | function createClientStream(socket) {
555 | const stream = PassThrough({ objectMode: true }).resume();
556 | stream.on('pause', () => {
557 | debug('c:pause', socket.remoteAddress);
558 | stream.unpipe().resume();
559 | });
560 | stream.pipe(socket);
561 | return stream;
562 | }
563 |
564 | function parseElement(element) {
565 | const parsed = {};
566 | if (element.startsWith('<')) {
567 | const [, attribute, content] = element.split(/[<>]/);
568 | const attributes = attribute.split(/ +/);
569 | parsed.name = attributes.shift();
570 | for (const a of attributes) {
571 | const [name, value] = a.split('=');
572 | parsed[name] = value?.split(/["']/)[1];
573 | }
574 | parsed.content = content;
575 | }
576 | debug(parsed);
577 | return parsed;
578 | }
579 |
580 | function handleThread(socket, client, element) {
581 | const thread = threads[element.thread];
582 | if (!thread) return;
583 | if (element.res_from < 0) {
584 | replayThread(socket, thread, element.res_from);
585 | } else {
586 | writeThread(socket, thread);
587 | }
588 | client.thread_stream?.unpipe(client.stream).resume();
589 | client.thread_stream = thread.stream;
590 | client.thread_stream.pipe(client.stream);
591 | debug('c:pipe', thread.id, socket.remoteAddress);
592 | clearInterval(client.counter);
593 | const counter_delay = 60;
594 | //const counter_delay = 10;
595 | client.counter = setInterval(
596 | () => writeViewCounter(socket, thread),
597 | counter_delay * 1000
598 | );
599 | client.leave = () => writeLeaveThread(socket, thread);
600 | }
601 |
602 | function replayThread(socket, thread, res_from) {
603 | const recent_res = thread.recent_res.filter(Boolean).splice(res_from);
604 | const last_res = thread.last_res - recent_res.length;
605 | writeThread(socket, { ...thread, last_res });
606 | for (const res of recent_res) {
607 | const element = createChatElement(thread, res);
608 | socket.write(`${element}\0`);
609 | }
610 | }
611 |
612 | function writeThread(socket, thread) {
613 | const { resultcode, last_res, ticket } = thread;
614 | const element = ``;
622 | socket.write(`${element}\0`);
623 | debug(element);
624 | }
625 |
626 | function writeViewCounter(socket, thread) {
627 | const id = thread.id - threads[thread.id].base_time + 1;
628 | const element = ``;
629 | socket.write(`${element}\0`);
630 | //debug(element);
631 | }
632 |
633 | function writeLeaveThread(socket, thread) {
634 | const element = ``;
638 | socket.write(`${element}\0`);
639 | debug(element);
640 | }
641 |
642 | function getNextBaseTimeDelay() {
643 | const next_base_time = getBaseTime() + 86400;
644 | return next_base_time - getServerTime();
645 | }
646 |
647 | function createThread(thread_id) {
648 | const recent_length = 50;
649 | const base_time = getBaseTime(thread_id);
650 | const thread = {
651 | resultcode: 0,
652 | id: thread_id,
653 | last_res: 0,
654 | last_min_res: Array(60 / force_delay).fill(0),
655 | recent_res: Array(recent_length),
656 | ticket: '0x25252525',
657 | revision: 1,
658 | base_time: base_time,
659 | open_time: base_time,
660 | start_time: base_time,
661 | end_time: base_time,
662 | force: 0,
663 | viewers: -1,
664 | comments: -1,
665 | lives: new Set()
666 | };
667 | thread.stream = createTransform(thread);
668 | thread.stream.resume();
669 | thread.stream.setMaxListeners(0);
670 | const prev_thread_id = thread_id - 86400;
671 | const prev_thread = threads[prev_thread_id];
672 | if (prev_thread) {
673 | thread.lives = prev_thread.lives;
674 | for (const live_video of thread.lives) {
675 | const live = lives[live_video];
676 | if (!live) continue;
677 | live.stream.unpipe(prev_thread.stream).resume();
678 | debug('unpipe', live_video, prev_thread.id);
679 | live.stream.pipe(thread.stream);
680 | debug('pipe', live_video, thread.id);
681 | }
682 | delete threads[prev_thread_id];
683 | }
684 | return thread;
685 | }
686 |
687 | function getThread(video, start_time) {
688 | const channel = channels[video];
689 | if (!channel) return;
690 | const thread_id = getBaseTime(start_time) + channel.thread_id;
691 | return threads[thread_id];
692 | }
693 |
694 | function createTransform(thread) {
695 | return Transform({
696 | objectMode: true,
697 | transform: function(chunk, encoding, callback) {
698 | chunk.no = ++thread.last_res;
699 | const element = createChatElement(thread, chunk);
700 | this.push(`${element}\0`);
701 | //debug(element);
702 | thread.recent_res.shift();
703 | thread.recent_res.push(chunk);
704 | thread.end_time = chunk.date;
705 | if (chunk.premium == 3 && chunk.content.startsWith('/')) {
706 | const commands = chunk.content.split(' ');
707 | debug(commands);
708 | if (commands[0] == '/jump') pipeLive(commands[1]);
709 | }
710 | callback();
711 | }
712 | });
713 | }
714 |
715 | function createChatElement(thread, chunk) {
716 | const {
717 | no,
718 | vpos_time,
719 | date,
720 | date_usec,
721 | mail,
722 | user_id,
723 | premium,
724 | anonymity,
725 | content
726 | } = chunk;
727 | const vpos = Math.round((vpos_time - thread.base_time) * 100);
728 | return `` +
739 | escapeChat(content) +
740 | ``;
741 | }
742 |
743 | function escapeChat(string) {
744 | return string.
745 | replace(/&/g, '&').
746 | replace(//g, '>');
748 | }
749 |
750 | function getLive(video) {
751 | return new Promise((resolve) => {
752 | if (lives[video]) return resolve(lives[video]);
753 | lives[video] = { stream: PassThrough({ objectMode: true }).resume() };
754 | (async () => {
755 | const retry = 10;
756 | for (let i = 0; i < retry; i++) {
757 | const live = await watchLive(video);
758 | if (!live) break;
759 | const { tags, community, stream, promise } = live;
760 | lives[video].tags = tags;
761 | lives[video].community = community;
762 | resolve(lives[video]);
763 | stream.pipe(lives[video].stream);
764 | await promise;
765 | stream.unpipe(lives[video].stream).resume();
766 | await waitSeconds(3);
767 | }
768 | delete lives[video];
769 | debug('delete', video);
770 | resolve({ tags: [] });
771 | })();
772 | });
773 | }
774 |
775 | async function watchLive(video) {
776 | const url = `https://live.nicovideo.jp/watch/${video}`;
777 | const embedded_data = await getEmbeddedData(url);
778 | if (!embedded_data) return;
779 | const { program: { tag, status }, community } = embedded_data;
780 | const watchable = new Set(['RELEASED', 'ON_AIR']);
781 | if (!watchable.has(status)) return;
782 | //const tags = tag.list.filter((t) => t.isLocked).map((t) => t.text);
783 | const tags = tag.list.map((t) => t.text);
784 | debug('watch', video, tags);
785 | const stream = PassThrough({ objectMode: true }).resume();
786 | const promise = promiseWatchLive(url, video, embedded_data, stream);
787 | return { tags, community, stream, promise };
788 | }
789 |
790 | async function promiseWatchLive(url, video, embedded_data, stream) {
791 | const {
792 | site: { relive: { webSocketUrl }, tag: { revisionCheckIntervalMs } },
793 | program: { beginTime, vposBaseTime },
794 | } = embedded_data;
795 | const getWebSocketUrl = async () => {
796 | debug('wait', url);
797 | await waitSeconds(beginTime - getServerTime());
798 | const embedded_data = await getEmbeddedData(url);
799 | return embedded_data?.site.relive.webSocketUrl;
800 | }
801 | const ws_url = webSocketUrl || await getWebSocketUrl();
802 | if (!ws_url) return;
803 | const { room, ws } = await createWebSocket(ws_url, video);
804 | const mws = createMessageWebSocket(room, revisionCheckIntervalMs);
805 | mws.on('message', (data) => {
806 | //debug(data);
807 | const message = JSON.parse(data);
808 | //debug('mws:message', mws._socket.remoteAddress, message);
809 | const { chat } = message;
810 | if (chat) {
811 | chat.vpos_time = vposBaseTime + chat.vpos / 100;
812 | stream.write(chat);
813 | //debug(chat);
814 | }
815 | });
816 | await new Promise((resolve) => {
817 | ws.on('close', () => {
818 | mws.close();
819 | resolve();
820 | });
821 | mws.on('close', () => {
822 | ws.close();
823 | resolve();
824 | });
825 | });
826 | }
827 |
828 | function createThreads() {
829 | const exclude_radio = true;
830 | const filter = exclude_radio ? (channel) => !channel.radiko_id : () => true;
831 | for (const channel of Object.values(channels).filter(filter)) {
832 | const thread_id = getBaseTime() + channel.thread_id;
833 | threads[thread_id] = createThread(thread_id);
834 | }
835 | }
836 |
837 | async function setPipesInterval() {
838 | const delay = 300;
839 | const pipe = () => pipeLives(['ニコニコ実況']);
840 | await pipe();
841 | return setInterval(pipe, delay * 1000);
842 | }
843 |
844 | function setForceInterval() {
845 | return setInterval(() => {
846 | for (const video of Object.keys(channels)) {
847 | const thread = getThread(video);
848 | if (thread) {
849 | thread.force = thread.last_res - thread.last_min_res.shift();
850 | thread.last_min_res.push(thread.last_res);
851 | }
852 | }
853 | }, force_delay * 1000);
854 | }
855 |
856 | async function pipeLives(tags, videos) {
857 | for (const status of ['onair', 'reserved']) {
858 | for (const live_video of await searchLives(tags, status)) {
859 | await pipeLive(live_video, videos);
860 | }
861 | }
862 | }
863 |
864 | async function pipeLive(live_video, videos = []) {
865 | const { tags, community, stream } = await getLive(live_video);
866 | const threads = new Set();
867 | videos.forEach((video) => threads.add(getThread(video)));
868 | tags.forEach((tag) => threads.add(getThreadByTag(tag)));
869 | if (community) threads.add(getThreadByCommunity(community));
870 | for (const thread of threads) {
871 | if (!thread) continue;
872 | if (!isPiped(stream, thread.stream)) {
873 | stream.pipe(thread.stream);
874 | debug('pipe', live_video, thread.id);
875 | thread.lives.add(live_video);
876 | }
877 | }
878 | }
879 |
880 | function getThreadByTag(tag) {
881 | if (tag.startsWith('jk')) return getThread(tag);
882 | const finder = (key) => channels[key].tags?.includes(tag);
883 | const video = Object.keys(channels).find(finder);
884 | return getThread(video);
885 | }
886 |
887 | function getThreadByCommunity(community) {
888 | const finder = (key) => channels[key].communities?.includes(community.id);
889 | const video = Object.keys(channels).find(finder);
890 | return getThread(video);
891 | }
892 |
893 | function isPiped(src, dest) {
894 | return src._readableState.pipes.includes(dest);
895 | }
896 |
897 | function closeServer(server) {
898 | return new Promise((resolve) => server.close(resolve));
899 | }
900 |
901 | async function getNgrokTunnel() {
902 | const url = 'http://localhost:4040/api/tunnels/namami';
903 | const res = await httpGet(url);
904 | const data = await slurpMessage(res);
905 | const { public_url, metrics: { conns: { rate1 } } } = JSON.parse(data);
906 | const threshold = 0.5;
907 | if (rate1 < threshold) {
908 | const { hostname, port } = new URL(public_url);
909 | const resolver = new Resolver();
910 | const [address] = await resolver.resolve4(hostname);
911 | return { address, port };
912 | }
913 | }
914 |
915 | (async () => {
916 | createThreads();
917 | const http_server = createHttpServer();
918 | const ws_server = createWebSocketServer(ms_port);
919 | await waitSeconds(getNextBaseTimeDelay());
920 | const restart = false;
921 | while (restart) {
922 | createThreads();
923 | await waitSeconds(getNextBaseTimeDelay());
924 | }
925 | closeServer(http_server);
926 | await closeServer(ws_server);
927 | process.exit();
928 | })();
929 |
930 | setForceInterval();
931 | setPipesInterval();
932 |
933 |
--------------------------------------------------------------------------------