@khorsheed/dsh-reader 0.3.0 → 0.3.1
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/CHANGELOG.md +22 -0
- package/README.en.md +26 -121
- package/README.i18n.yaml +3 -3
- package/README.md +26 -121
- package/cordis.patch.yml +7 -0
- package/lib/client.js +29054 -6698
- package/lib/index.js +3416 -334
- package/lib/typert.host.js +2145 -259
- package/lib/typert.remote-client.d.ts +97 -3
- package/lib/typert.remote-client.js +1590 -250
- package/lib/types/captions.d.ts +99 -0
- package/lib/types/captions.js +236 -0
- package/lib/types/client/MediaPlayer.d.ts +55 -0
- package/lib/types/client/MediaPlayer.js +106 -0
- package/lib/types/client/NotesView.d.ts +57 -0
- package/lib/types/client/NotesView.js +124 -0
- package/lib/types/client/ReaderPane.d.ts +20 -3
- package/lib/types/client/ReaderPane.js +860 -164
- package/lib/types/client/SpeakerLegend.d.ts +18 -0
- package/lib/types/client/SpeakerLegend.js +26 -0
- package/lib/types/client/canvas-sync.d.ts +84 -0
- package/lib/types/client/canvas-sync.js +59 -0
- package/lib/types/client/contract.d.ts +130 -3
- package/lib/types/client/extract-article.d.ts +38 -1
- package/lib/types/client/extract-article.js +501 -28
- package/lib/types/client/icons.d.ts +1 -1
- package/lib/types/client/icons.js +1 -1
- package/lib/types/client/index.js +81 -2
- package/lib/types/client/locales.d.ts +279 -11
- package/lib/types/client/locales.js +278 -10
- package/lib/types/client/notes.d.ts +142 -0
- package/lib/types/client/notes.js +334 -0
- package/lib/types/client/parse-rss.js +40 -0
- package/lib/types/client/preset-visibility.d.ts +93 -0
- package/lib/types/client/preset-visibility.js +115 -0
- package/lib/types/client/selectors.d.ts +5 -0
- package/lib/types/client/selectors.js +5 -0
- package/lib/types/client/session.d.ts +21 -1
- package/lib/types/client/session.js +12 -0
- package/lib/types/client/store.js +4 -1
- package/lib/types/client/translate.d.ts +26 -1
- package/lib/types/client/translate.js +29 -12
- package/lib/types/client/useLocalTranscript.d.ts +43 -0
- package/lib/types/client/useLocalTranscript.js +129 -0
- package/lib/types/client/useMedia.d.ts +68 -0
- package/lib/types/client/useMedia.js +299 -0
- package/lib/types/client/useMediaStates.d.ts +9 -0
- package/lib/types/client/useMediaStates.js +43 -0
- package/lib/types/client/useNotes.d.ts +110 -0
- package/lib/types/client/useNotes.js +423 -0
- package/lib/types/client/usePolish.d.ts +47 -0
- package/lib/types/client/usePolish.js +162 -0
- package/lib/types/index.d.ts +6 -0
- package/lib/types/index.js +3 -0
- package/lib/types/local-transcribe.d.ts +171 -0
- package/lib/types/local-transcribe.js +473 -0
- package/lib/types/media-host.d.ts +91 -0
- package/lib/types/media-host.js +230 -0
- package/lib/types/media.d.ts +100 -0
- package/lib/types/media.js +207 -0
- package/lib/types/notes-types.d.ts +158 -0
- package/lib/types/notes-types.js +8 -0
- package/lib/types/notes.d.ts +213 -0
- package/lib/types/notes.js +718 -0
- package/lib/types/polish.d.ts +41 -0
- package/lib/types/polish.js +136 -0
- package/lib/types/remote.d.ts +118 -0
- package/lib/types/remote.js +151 -1
- package/lib/types/service.d.ts +71 -1
- package/lib/types/service.js +280 -59
- package/lib/types/skill.d.ts +20 -0
- package/lib/types/skill.js +78 -0
- package/lib/types/store.js +30 -8
- package/lib/types/tool.d.ts +23 -0
- package/lib/types/tool.js +40 -0
- package/lib/types/tools.d.ts +69 -0
- package/lib/types/tools.js +400 -0
- package/lib/types/types.d.ts +87 -0
- package/lib/types/types.js +66 -0
- package/lib/types/xiaoyuzhou.d.ts +43 -0
- package/lib/types/xiaoyuzhou.js +134 -0
- package/lib/types/ytdlp.d.ts +111 -0
- package/lib/types/ytdlp.js +269 -0
- package/package.json +29 -20
- package/lib/invariant.js +0 -38
- package/lib/types/invariant.d.ts +0 -29
- package/lib/types/invariant.js +0 -38
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { type ReaderMedia } from './media.ts';
|
|
2
|
+
import type { XiaoyuzhouEpisode } from './xiaoyuzhou.ts';
|
|
3
|
+
import { YtDlp, type YtDlpInfo } from './ytdlp.ts';
|
|
4
|
+
/** What a video's captions came to, remembered per entry. */
|
|
5
|
+
export interface CaptionRecord {
|
|
6
|
+
/** `local`: transcribed on this machine (the platform had no captions). */
|
|
7
|
+
readonly state: 'platform' | 'none' | 'local';
|
|
8
|
+
readonly language?: string;
|
|
9
|
+
readonly checkedAt: string;
|
|
10
|
+
/** How many voices a local transcript has (1: the probe heard one; ≥ 2: paragraphs carry `data-spk`). */
|
|
11
|
+
readonly speakers?: number;
|
|
12
|
+
/** The reader's names for the speakers, by index (`0` → 主持人). */
|
|
13
|
+
readonly names?: Readonly<Record<string, string>>;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The synthetic page a media link is stored as.
|
|
17
|
+
*
|
|
18
|
+
* @param page - what the page says.
|
|
19
|
+
*/
|
|
20
|
+
export declare function syntheticPage(page: {
|
|
21
|
+
title: string;
|
|
22
|
+
media: ReaderMedia;
|
|
23
|
+
descriptionText?: string;
|
|
24
|
+
descriptionHtml?: string;
|
|
25
|
+
author?: string;
|
|
26
|
+
publishedAt?: string;
|
|
27
|
+
}): string;
|
|
28
|
+
/**
|
|
29
|
+
* A video's synthetic page from its yt-dlp metadata.
|
|
30
|
+
*
|
|
31
|
+
* @param info - the metadata.
|
|
32
|
+
* @param provider - which platform.
|
|
33
|
+
* @param id - the platform's video id.
|
|
34
|
+
*/
|
|
35
|
+
export declare function videoPage(info: YtDlpInfo, provider: 'youtube' | 'bilibili', id: string): string;
|
|
36
|
+
/**
|
|
37
|
+
* A Xiaoyuzhou episode's synthetic page.
|
|
38
|
+
*
|
|
39
|
+
* @param episode - the parsed episode.
|
|
40
|
+
*/
|
|
41
|
+
export declare function episodePage(episode: XiaoyuzhouEpisode): string;
|
|
42
|
+
/**
|
|
43
|
+
* A YouTube channel's newest videos as an RSS document (YouTube's own channel
|
|
44
|
+
* feed answers 404 — measured 2026-10-20). Each item links the watch page, so
|
|
45
|
+
* the browser half reads it as a video; the thumbnail rides `media:thumbnail`.
|
|
46
|
+
*
|
|
47
|
+
* @param channel - the yt-dlp flat listing.
|
|
48
|
+
* @param pageUrl - the channel URL.
|
|
49
|
+
*/
|
|
50
|
+
export declare function channelRss(channel: {
|
|
51
|
+
title?: string;
|
|
52
|
+
entries: readonly YtDlpInfo[];
|
|
53
|
+
}, pageUrl: string): string;
|
|
54
|
+
/** A synthetic page's display name for the source: its author (the channel / podcast), else its title. */
|
|
55
|
+
export declare function syntheticLabel(raw: string): string | undefined;
|
|
56
|
+
/**
|
|
57
|
+
* Where a share short link leads (b23.tv → bilibili.com/video/BV…). curl
|
|
58
|
+
* follows the redirect chain and honors the deployment's proxy environment.
|
|
59
|
+
*
|
|
60
|
+
* @param url - the short link.
|
|
61
|
+
*/
|
|
62
|
+
export declare function resolveShortLink(url: string): Promise<string>;
|
|
63
|
+
/** The host's media helper: yt-dlp plus the caption record. */
|
|
64
|
+
export declare class ReaderMediaHost {
|
|
65
|
+
private readonly stateRoot;
|
|
66
|
+
readonly ytdlp: YtDlp;
|
|
67
|
+
private readonly file;
|
|
68
|
+
private records;
|
|
69
|
+
/**
|
|
70
|
+
* @param stateRoot - the reader's state root.
|
|
71
|
+
* @param ytdlpPath - a deployment-configured yt-dlp, which wins over the managed one.
|
|
72
|
+
*/
|
|
73
|
+
constructor(stateRoot: string, ytdlpPath?: string);
|
|
74
|
+
/** The caption records, read once. */
|
|
75
|
+
private load;
|
|
76
|
+
/** What is known about an entry's captions. */
|
|
77
|
+
captions(entryId: string): CaptionRecord | undefined;
|
|
78
|
+
/** Remember what an entry's captions came to. */
|
|
79
|
+
remember(entryId: string, record: CaptionRecord): void;
|
|
80
|
+
/**
|
|
81
|
+
* A video's transcript from its platform captions.
|
|
82
|
+
*
|
|
83
|
+
* @param url - the video page.
|
|
84
|
+
* @returns the transcript markup and its language, or `none` when the video has no captions.
|
|
85
|
+
*/
|
|
86
|
+
transcript(url: string): Promise<{
|
|
87
|
+
html: string;
|
|
88
|
+
language: string;
|
|
89
|
+
} | 'none'>;
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=media-host.d.ts.map
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host half of video and audio entries: the pages a media link becomes,
|
|
3
|
+
* the feeds a channel or podcast becomes, and the transcript a video reads as.
|
|
4
|
+
*
|
|
5
|
+
* - A single video (YouTube, Bilibili) or Xiaoyuzhou episode is stored as a
|
|
6
|
+
* `link` source whose payload is a SYNTHETIC page: title, description,
|
|
7
|
+
* thumbnail, and a `<meta name="dsh-reader:media">` carrying the media
|
|
8
|
+
* description the browser half reads (`readMediaMeta`). Every other part of
|
|
9
|
+
* the reader then treats it as a saved link.
|
|
10
|
+
* - A YouTube channel resolves to YouTube's own channel feed; a Xiaoyuzhou
|
|
11
|
+
* podcast page becomes an RSS document (`xiaoyuzhou.ts`).
|
|
12
|
+
* - A video's transcript comes from the platform's captions via yt-dlp,
|
|
13
|
+
* merged into timed paragraphs (`captions.ts`). Whether a video has
|
|
14
|
+
* captions is remembered in `media.json`, so a caption-less video does not
|
|
15
|
+
* cost a yt-dlp run on every open.
|
|
16
|
+
*
|
|
17
|
+
* @module @khorsheed/dsh-reader/media-host
|
|
18
|
+
*/
|
|
19
|
+
import { execFile } from 'node:child_process';
|
|
20
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { mergeCues, renderTranscript } from "./captions.js";
|
|
23
|
+
import { MEDIA_META_NAME } from "./media.js";
|
|
24
|
+
import { chooseCaptionTrack, YtDlp } from "./ytdlp.js";
|
|
25
|
+
/** Escape text for HTML content and attributes. */
|
|
26
|
+
function escapeHtml(text) {
|
|
27
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
28
|
+
}
|
|
29
|
+
/** A plain-text description as paragraphs. */
|
|
30
|
+
function paragraphs(text) {
|
|
31
|
+
return text.split(/\n{2,}/).map(block => block.trim()).filter(block => block !== '')
|
|
32
|
+
.map(block => `<p>${escapeHtml(block).replace(/\n/g, '<br>')}</p>`).join('\n');
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The synthetic page a media link is stored as.
|
|
36
|
+
*
|
|
37
|
+
* @param page - what the page says.
|
|
38
|
+
*/
|
|
39
|
+
export function syntheticPage(page) {
|
|
40
|
+
const body = page.descriptionHtml ?? (page.descriptionText === undefined ? '' : paragraphs(page.descriptionText));
|
|
41
|
+
const plain = (page.descriptionText ?? page.descriptionHtml?.replace(/<[^>]+>/g, ' ') ?? '').replace(/\s+/g, ' ').trim().slice(0, 300);
|
|
42
|
+
return [
|
|
43
|
+
'<!doctype html><html><head><meta charset="utf-8">',
|
|
44
|
+
`<title>${escapeHtml(page.title)}</title>`,
|
|
45
|
+
`<meta property="og:title" content="${escapeHtml(page.title)}">`,
|
|
46
|
+
plain === '' ? '' : `<meta name="description" content="${escapeHtml(plain)}">`,
|
|
47
|
+
page.media.thumbnail === undefined ? '' : `<meta property="og:image" content="${escapeHtml(page.media.thumbnail)}">`,
|
|
48
|
+
page.author === undefined ? '' : `<meta name="author" content="${escapeHtml(page.author)}">`,
|
|
49
|
+
page.publishedAt === undefined ? '' : `<meta property="article:published_time" content="${escapeHtml(page.publishedAt)}">`,
|
|
50
|
+
`<meta name="${MEDIA_META_NAME}" content="${escapeHtml(JSON.stringify(page.media))}">`,
|
|
51
|
+
'</head><body><article>',
|
|
52
|
+
`<h1>${escapeHtml(page.title)}</h1>`,
|
|
53
|
+
body,
|
|
54
|
+
'</article></body></html>',
|
|
55
|
+
].filter(line => line !== '').join('\n');
|
|
56
|
+
}
|
|
57
|
+
/** A yt-dlp `upload_date` (`YYYYMMDD`) or timestamp as ISO-8601. */
|
|
58
|
+
function publishedOf(info) {
|
|
59
|
+
if (typeof info.timestamp === 'number')
|
|
60
|
+
return new Date(info.timestamp * 1000).toISOString();
|
|
61
|
+
const day = /^(\d{4})(\d{2})(\d{2})$/.exec(info.upload_date ?? '');
|
|
62
|
+
return day === null ? undefined : `${day[1]}-${day[2]}-${day[3]}T00:00:00.000Z`;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A video's synthetic page from its yt-dlp metadata.
|
|
66
|
+
*
|
|
67
|
+
* @param info - the metadata.
|
|
68
|
+
* @param provider - which platform.
|
|
69
|
+
* @param id - the platform's video id.
|
|
70
|
+
*/
|
|
71
|
+
export function videoPage(info, provider, id) {
|
|
72
|
+
const published = publishedOf(info);
|
|
73
|
+
const author = info.uploader ?? info.channel;
|
|
74
|
+
return syntheticPage({
|
|
75
|
+
title: info.title ?? id,
|
|
76
|
+
media: {
|
|
77
|
+
kind: 'video',
|
|
78
|
+
provider,
|
|
79
|
+
ref: id,
|
|
80
|
+
...(typeof info.duration === 'number' ? { durationSec: Math.round(info.duration) } : {}),
|
|
81
|
+
...(info.thumbnail === undefined ? {} : { thumbnail: info.thumbnail }),
|
|
82
|
+
},
|
|
83
|
+
...(info.description === undefined ? {} : { descriptionText: info.description }),
|
|
84
|
+
...(author === undefined ? {} : { author }),
|
|
85
|
+
...(published === undefined ? {} : { publishedAt: published }),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* A Xiaoyuzhou episode's synthetic page.
|
|
90
|
+
*
|
|
91
|
+
* @param episode - the parsed episode.
|
|
92
|
+
*/
|
|
93
|
+
export function episodePage(episode) {
|
|
94
|
+
return syntheticPage({
|
|
95
|
+
title: episode.title,
|
|
96
|
+
media: {
|
|
97
|
+
kind: 'audio',
|
|
98
|
+
provider: 'xiaoyuzhou',
|
|
99
|
+
ref: episode.audio,
|
|
100
|
+
...(episode.durationSec === undefined ? {} : { durationSec: episode.durationSec }),
|
|
101
|
+
...(episode.image === undefined ? {} : { thumbnail: episode.image }),
|
|
102
|
+
},
|
|
103
|
+
...(episode.notesHtml === undefined ? {} : { descriptionHtml: episode.notesHtml }),
|
|
104
|
+
...(episode.podcastTitle === undefined ? {} : { author: episode.podcastTitle }),
|
|
105
|
+
...(episode.publishedAt === undefined ? {} : { publishedAt: episode.publishedAt }),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
/** Escape text for XML. */
|
|
109
|
+
function xml(text) {
|
|
110
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* A YouTube channel's newest videos as an RSS document (YouTube's own channel
|
|
114
|
+
* feed answers 404 — measured 2026-10-20). Each item links the watch page, so
|
|
115
|
+
* the browser half reads it as a video; the thumbnail rides `media:thumbnail`.
|
|
116
|
+
*
|
|
117
|
+
* @param channel - the yt-dlp flat listing.
|
|
118
|
+
* @param pageUrl - the channel URL.
|
|
119
|
+
*/
|
|
120
|
+
export function channelRss(channel, pageUrl) {
|
|
121
|
+
const items = channel.entries.flatMap(entry => {
|
|
122
|
+
if (entry.id === undefined || entry.title === undefined)
|
|
123
|
+
return [];
|
|
124
|
+
const published = publishedOf(entry);
|
|
125
|
+
return [[
|
|
126
|
+
'<item>',
|
|
127
|
+
`<title>${xml(entry.title)}</title>`,
|
|
128
|
+
`<link>https://www.youtube.com/watch?v=${xml(entry.id)}</link>`,
|
|
129
|
+
`<guid isPermaLink="false">yt:video:${xml(entry.id)}</guid>`,
|
|
130
|
+
published === undefined ? '' : `<pubDate>${new Date(published).toUTCString()}</pubDate>`,
|
|
131
|
+
entry.thumbnail === undefined ? '' : `<media:thumbnail url="${xml(entry.thumbnail)}"/>`,
|
|
132
|
+
'</item>',
|
|
133
|
+
].filter(line => line !== '').join('')];
|
|
134
|
+
});
|
|
135
|
+
return [
|
|
136
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
137
|
+
'<rss version="2.0" xmlns:media="http://search.yahoo.com/mrss/"><channel>',
|
|
138
|
+
`<title>${xml(channel.title ?? pageUrl)}</title>`,
|
|
139
|
+
`<link>${xml(pageUrl)}</link>`,
|
|
140
|
+
...items,
|
|
141
|
+
'</channel></rss>',
|
|
142
|
+
].join('\n');
|
|
143
|
+
}
|
|
144
|
+
/** A synthetic page's display name for the source: its author (the channel / podcast), else its title. */
|
|
145
|
+
export function syntheticLabel(raw) {
|
|
146
|
+
const author = /<meta name="author" content="([^"]*)"/.exec(raw)?.[1];
|
|
147
|
+
const title = /<title>([^<]*)<\/title>/.exec(raw)?.[1];
|
|
148
|
+
const value = author ?? title;
|
|
149
|
+
return value === undefined ? undefined : value.replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Where a share short link leads (b23.tv → bilibili.com/video/BV…). curl
|
|
153
|
+
* follows the redirect chain and honors the deployment's proxy environment.
|
|
154
|
+
*
|
|
155
|
+
* @param url - the short link.
|
|
156
|
+
*/
|
|
157
|
+
export function resolveShortLink(url) {
|
|
158
|
+
return new Promise((resolve, reject) => {
|
|
159
|
+
execFile('curl', ['-sL', '-m', '20', '-A', 'Mozilla/5.0', '-o', '/dev/null', '-w', '%{url_effective}', url], { timeout: 30_000, env: process.env }, (error, stdout) => {
|
|
160
|
+
if (error !== null) {
|
|
161
|
+
reject(error);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
resolve(String(stdout).trim());
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
/** The host's media helper: yt-dlp plus the caption record. */
|
|
169
|
+
export class ReaderMediaHost {
|
|
170
|
+
stateRoot;
|
|
171
|
+
ytdlp;
|
|
172
|
+
file;
|
|
173
|
+
records;
|
|
174
|
+
/**
|
|
175
|
+
* @param stateRoot - the reader's state root.
|
|
176
|
+
* @param ytdlpPath - a deployment-configured yt-dlp, which wins over the managed one.
|
|
177
|
+
*/
|
|
178
|
+
constructor(stateRoot, ytdlpPath) {
|
|
179
|
+
this.stateRoot = stateRoot;
|
|
180
|
+
this.ytdlp = new YtDlp(stateRoot, ytdlpPath);
|
|
181
|
+
this.file = join(stateRoot, 'media.json');
|
|
182
|
+
}
|
|
183
|
+
/** The caption records, read once. */
|
|
184
|
+
load() {
|
|
185
|
+
if (this.records !== undefined)
|
|
186
|
+
return this.records;
|
|
187
|
+
try {
|
|
188
|
+
this.records = JSON.parse(readFileSync(this.file, 'utf8'));
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
this.records = {};
|
|
192
|
+
}
|
|
193
|
+
return this.records;
|
|
194
|
+
}
|
|
195
|
+
/** What is known about an entry's captions. */
|
|
196
|
+
captions(entryId) {
|
|
197
|
+
return this.load()[entryId];
|
|
198
|
+
}
|
|
199
|
+
/** Remember what an entry's captions came to. */
|
|
200
|
+
remember(entryId, record) {
|
|
201
|
+
const records = { ...this.load(), [entryId]: record };
|
|
202
|
+
this.records = records;
|
|
203
|
+
try {
|
|
204
|
+
mkdirSync(this.stateRoot, { recursive: true });
|
|
205
|
+
const temporary = `${this.file}.${process.pid}.tmp`;
|
|
206
|
+
writeFileSync(temporary, `${JSON.stringify(records, null, 2)}\n`);
|
|
207
|
+
renameSync(temporary, this.file);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
// Memory-only: the record still saves this process the repeat run.
|
|
211
|
+
rmSync(`${this.file}.${process.pid}.tmp`, { force: true });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* A video's transcript from its platform captions.
|
|
216
|
+
*
|
|
217
|
+
* @param url - the video page.
|
|
218
|
+
* @returns the transcript markup and its language, or `none` when the video has no captions.
|
|
219
|
+
*/
|
|
220
|
+
async transcript(url) {
|
|
221
|
+
const info = await this.ytdlp.info(url);
|
|
222
|
+
const track = chooseCaptionTrack(info);
|
|
223
|
+
if (track === undefined)
|
|
224
|
+
return 'none';
|
|
225
|
+
const segments = mergeCues(await this.ytdlp.captions(url, track));
|
|
226
|
+
if (segments.length === 0)
|
|
227
|
+
return 'none';
|
|
228
|
+
return { html: renderTranscript(segments), language: track.lang.replace(/-orig$/, '') };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Video and audio entries: what kind of media a link or a feed item is, and
|
|
3
|
+
* how the browser plays it.
|
|
4
|
+
*
|
|
5
|
+
* The kind is decided per ITEM, never per source (the 2026-10-20 decision): a
|
|
6
|
+
* feed may carry articles, audio and video side by side. The signals, best
|
|
7
|
+
* first, are a feed enclosure's MIME type, a link the resolvers recognize
|
|
8
|
+
* (YouTube, Bilibili, Xiaoyuzhou), and — on the host, where pages are fetched
|
|
9
|
+
* — a page whose main content is a player. A page with real prose stays an
|
|
10
|
+
* article even when it embeds a video.
|
|
11
|
+
*
|
|
12
|
+
* Pure and shared by both halves.
|
|
13
|
+
*
|
|
14
|
+
* @module @khorsheed/dsh-reader/media
|
|
15
|
+
*/
|
|
16
|
+
/** Where a media entry plays from. */
|
|
17
|
+
export type ReaderMediaProvider = 'youtube' | 'bilibili' | 'xiaoyuzhou' | 'enclosure';
|
|
18
|
+
/** A media entry's description, carried on the entry. */
|
|
19
|
+
export interface ReaderMedia {
|
|
20
|
+
readonly kind: 'video' | 'audio';
|
|
21
|
+
readonly provider: ReaderMediaProvider;
|
|
22
|
+
/** The platform's id (YouTube video id, Bilibili BV id) or the audio URL. */
|
|
23
|
+
readonly ref: string;
|
|
24
|
+
readonly durationSec?: number;
|
|
25
|
+
readonly thumbnail?: string;
|
|
26
|
+
}
|
|
27
|
+
/** A recognized media link. */
|
|
28
|
+
export interface MediaLink {
|
|
29
|
+
readonly provider: 'youtube' | 'bilibili' | 'xiaoyuzhou';
|
|
30
|
+
readonly kind: 'video' | 'audio' | 'channel' | 'podcast';
|
|
31
|
+
/** Video id / BV id / episode id / channel handle-or-id / podcast id. */
|
|
32
|
+
readonly id: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Recognize a media link from its URL alone.
|
|
36
|
+
*
|
|
37
|
+
* @param input - any URL.
|
|
38
|
+
* @returns the link, or `undefined` for anything else.
|
|
39
|
+
*/
|
|
40
|
+
export declare function mediaLink(input: string): MediaLink | undefined;
|
|
41
|
+
/** True for a share short link that must be resolved before it can be recognized. */
|
|
42
|
+
export declare function isShortMediaLink(input: string): boolean;
|
|
43
|
+
/**
|
|
44
|
+
* The clean address of a recognized video link: the platform's canonical page,
|
|
45
|
+
* without the share-tracking parameters a resolved short link drags along.
|
|
46
|
+
*
|
|
47
|
+
* @param input - a video URL.
|
|
48
|
+
*/
|
|
49
|
+
export declare function canonicalMediaUrl(input: string): string | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* The media kind of a feed enclosure, from its MIME type (or, when a feed
|
|
52
|
+
* omits it, from the file extension).
|
|
53
|
+
*
|
|
54
|
+
* @param url - the enclosure URL.
|
|
55
|
+
* @param type - the declared MIME type, when present.
|
|
56
|
+
*/
|
|
57
|
+
export declare function enclosureKind(url: string, type?: string): 'video' | 'audio' | undefined;
|
|
58
|
+
/**
|
|
59
|
+
* The media an entry plays, from what the feed said and where it links.
|
|
60
|
+
*
|
|
61
|
+
* @param entry - the entry's link and its enclosure, when the feed had one.
|
|
62
|
+
*/
|
|
63
|
+
export declare function entryMedia(entry: {
|
|
64
|
+
link?: string;
|
|
65
|
+
enclosure?: {
|
|
66
|
+
url: string;
|
|
67
|
+
type?: string;
|
|
68
|
+
durationSec?: number;
|
|
69
|
+
image?: string;
|
|
70
|
+
};
|
|
71
|
+
}): ReaderMedia | undefined;
|
|
72
|
+
/**
|
|
73
|
+
* The embed URL a video plays in, starting at `start` seconds.
|
|
74
|
+
*
|
|
75
|
+
* YouTube's embed takes postMessage commands (`enablejsapi=1`, with the
|
|
76
|
+
* embedding origin); Bilibili's has no control channel, so a seek reloads it
|
|
77
|
+
* with `t` (measured 2026-10-20).
|
|
78
|
+
*
|
|
79
|
+
* @param media - the entry's media.
|
|
80
|
+
* @param start - where to start (seconds).
|
|
81
|
+
* @param origin - the embedding page's origin (YouTube checks it).
|
|
82
|
+
*/
|
|
83
|
+
export declare function embedUrl(media: ReaderMedia, start: number, origin: string): string | undefined;
|
|
84
|
+
/**
|
|
85
|
+
* A link to the original at a moment (what notes and canvas cards carry).
|
|
86
|
+
*
|
|
87
|
+
* @param link - the entry's link.
|
|
88
|
+
* @param seconds - the moment.
|
|
89
|
+
*/
|
|
90
|
+
export declare function linkAt(link: string, seconds: number): string;
|
|
91
|
+
/** The `<meta name>` a media link's synthetic page carries its description in. */
|
|
92
|
+
export declare const MEDIA_META_NAME = "dsh-reader:media";
|
|
93
|
+
/**
|
|
94
|
+
* The media description a synthetic page carries (see `media-host.ts`).
|
|
95
|
+
*
|
|
96
|
+
* @param raw - a stored link payload.
|
|
97
|
+
* @returns the media, or `undefined` for an ordinary page.
|
|
98
|
+
*/
|
|
99
|
+
export declare function readMediaMeta(raw: string): ReaderMedia | undefined;
|
|
100
|
+
//# sourceMappingURL=media.d.ts.map
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Video and audio entries: what kind of media a link or a feed item is, and
|
|
3
|
+
* how the browser plays it.
|
|
4
|
+
*
|
|
5
|
+
* The kind is decided per ITEM, never per source (the 2026-10-20 decision): a
|
|
6
|
+
* feed may carry articles, audio and video side by side. The signals, best
|
|
7
|
+
* first, are a feed enclosure's MIME type, a link the resolvers recognize
|
|
8
|
+
* (YouTube, Bilibili, Xiaoyuzhou), and — on the host, where pages are fetched
|
|
9
|
+
* — a page whose main content is a player. A page with real prose stays an
|
|
10
|
+
* article even when it embeds a video.
|
|
11
|
+
*
|
|
12
|
+
* Pure and shared by both halves.
|
|
13
|
+
*
|
|
14
|
+
* @module @khorsheed/dsh-reader/media
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Recognize a media link from its URL alone.
|
|
18
|
+
*
|
|
19
|
+
* @param input - any URL.
|
|
20
|
+
* @returns the link, or `undefined` for anything else.
|
|
21
|
+
*/
|
|
22
|
+
export function mediaLink(input) {
|
|
23
|
+
let url;
|
|
24
|
+
try {
|
|
25
|
+
url = new URL(input);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
const host = url.hostname.replace(/^(www|m)\./, '');
|
|
31
|
+
if (host === 'youtube.com' || host === 'music.youtube.com') {
|
|
32
|
+
const v = url.searchParams.get('v');
|
|
33
|
+
if (url.pathname === '/watch' && v !== null && /^[\w-]{6,}$/.test(v))
|
|
34
|
+
return { provider: 'youtube', kind: 'video', id: v };
|
|
35
|
+
const short = /^\/(?:shorts|live|embed)\/([\w-]{6,})/.exec(url.pathname);
|
|
36
|
+
if (short !== null)
|
|
37
|
+
return { provider: 'youtube', kind: 'video', id: short[1] };
|
|
38
|
+
const channel = /^\/(@[\w.-]+|channel\/UC[\w-]{10,}|c\/[\w.-]+|user\/[\w.-]+)\/?$/.exec(url.pathname.replace(/\/(videos|featured|streams)\/?$/, '/'));
|
|
39
|
+
if (channel !== null)
|
|
40
|
+
return { provider: 'youtube', kind: 'channel', id: channel[1] };
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
if (host === 'youtu.be') {
|
|
44
|
+
const id = url.pathname.slice(1);
|
|
45
|
+
return /^[\w-]{6,}$/.test(id) ? { provider: 'youtube', kind: 'video', id } : undefined;
|
|
46
|
+
}
|
|
47
|
+
if (host === 'bilibili.com' || host === 'b23.tv') {
|
|
48
|
+
const bv = /\/video\/(BV[0-9A-Za-z]{10})/.exec(url.pathname);
|
|
49
|
+
if (bv !== null)
|
|
50
|
+
return { provider: 'bilibili', kind: 'video', id: bv[1] };
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
if (host === 'xiaoyuzhoufm.com') {
|
|
54
|
+
const episode = /^\/episode\/([0-9a-f]{24})/.exec(url.pathname);
|
|
55
|
+
if (episode !== null)
|
|
56
|
+
return { provider: 'xiaoyuzhou', kind: 'audio', id: episode[1] };
|
|
57
|
+
const podcast = /^\/podcast\/([0-9a-f]{24})/.exec(url.pathname);
|
|
58
|
+
if (podcast !== null)
|
|
59
|
+
return { provider: 'xiaoyuzhou', kind: 'podcast', id: podcast[1] };
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
/** Hosts whose links are only redirects to a media page (Bilibili's share links). */
|
|
64
|
+
const SHORT_LINK_HOSTS = new Set(['b23.tv', 'bili2233.cn']);
|
|
65
|
+
/** True for a share short link that must be resolved before it can be recognized. */
|
|
66
|
+
export function isShortMediaLink(input) {
|
|
67
|
+
try {
|
|
68
|
+
return SHORT_LINK_HOSTS.has(new URL(input).hostname.replace(/^www\./, ''));
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The clean address of a recognized video link: the platform's canonical page,
|
|
76
|
+
* without the share-tracking parameters a resolved short link drags along.
|
|
77
|
+
*
|
|
78
|
+
* @param input - a video URL.
|
|
79
|
+
*/
|
|
80
|
+
export function canonicalMediaUrl(input) {
|
|
81
|
+
const link = mediaLink(input);
|
|
82
|
+
if (link?.provider === 'bilibili' && link.kind === 'video') {
|
|
83
|
+
const page = new URL(input).searchParams.get('p');
|
|
84
|
+
return `https://www.bilibili.com/video/${link.id}/${page !== null && page !== '1' ? `?p=${encodeURIComponent(page)}` : ''}`;
|
|
85
|
+
}
|
|
86
|
+
if (link?.provider === 'youtube' && link.kind === 'video')
|
|
87
|
+
return `https://www.youtube.com/watch?v=${link.id}`;
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The media kind of a feed enclosure, from its MIME type (or, when a feed
|
|
92
|
+
* omits it, from the file extension).
|
|
93
|
+
*
|
|
94
|
+
* @param url - the enclosure URL.
|
|
95
|
+
* @param type - the declared MIME type, when present.
|
|
96
|
+
*/
|
|
97
|
+
export function enclosureKind(url, type) {
|
|
98
|
+
const mime = (type ?? '').toLowerCase();
|
|
99
|
+
if (mime.startsWith('audio/'))
|
|
100
|
+
return 'audio';
|
|
101
|
+
if (mime.startsWith('video/'))
|
|
102
|
+
return 'video';
|
|
103
|
+
const path = url.toLowerCase().split('?')[0] ?? '';
|
|
104
|
+
if (/\.(mp3|m4a|aac|ogg|oga|opus|wav|flac)$/.test(path))
|
|
105
|
+
return 'audio';
|
|
106
|
+
if (/\.(mp4|m4v|mov|webm)$/.test(path))
|
|
107
|
+
return 'video';
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The media an entry plays, from what the feed said and where it links.
|
|
112
|
+
*
|
|
113
|
+
* @param entry - the entry's link and its enclosure, when the feed had one.
|
|
114
|
+
*/
|
|
115
|
+
export function entryMedia(entry) {
|
|
116
|
+
const linked = entry.link === undefined ? undefined : mediaLink(entry.link);
|
|
117
|
+
if (linked !== undefined && linked.kind === 'video') {
|
|
118
|
+
return { kind: 'video', provider: linked.provider, ref: linked.id, ...(entry.enclosure?.image === undefined ? {} : { thumbnail: entry.enclosure.image }) };
|
|
119
|
+
}
|
|
120
|
+
if (entry.enclosure !== undefined) {
|
|
121
|
+
const kind = enclosureKind(entry.enclosure.url, entry.enclosure.type);
|
|
122
|
+
if (kind !== undefined) {
|
|
123
|
+
return {
|
|
124
|
+
kind,
|
|
125
|
+
provider: 'enclosure',
|
|
126
|
+
ref: entry.enclosure.url,
|
|
127
|
+
...(entry.enclosure.durationSec === undefined ? {} : { durationSec: entry.enclosure.durationSec }),
|
|
128
|
+
...(entry.enclosure.image === undefined ? {} : { thumbnail: entry.enclosure.image }),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The embed URL a video plays in, starting at `start` seconds.
|
|
136
|
+
*
|
|
137
|
+
* YouTube's embed takes postMessage commands (`enablejsapi=1`, with the
|
|
138
|
+
* embedding origin); Bilibili's has no control channel, so a seek reloads it
|
|
139
|
+
* with `t` (measured 2026-10-20).
|
|
140
|
+
*
|
|
141
|
+
* @param media - the entry's media.
|
|
142
|
+
* @param start - where to start (seconds).
|
|
143
|
+
* @param origin - the embedding page's origin (YouTube checks it).
|
|
144
|
+
*/
|
|
145
|
+
export function embedUrl(media, start, origin) {
|
|
146
|
+
const t = Math.max(0, Math.floor(start));
|
|
147
|
+
if (media.provider === 'youtube') {
|
|
148
|
+
return `https://www.youtube.com/embed/${encodeURIComponent(media.ref)}?enablejsapi=1&playsinline=1&rel=0&start=${String(t)}&origin=${encodeURIComponent(origin)}`;
|
|
149
|
+
}
|
|
150
|
+
if (media.provider === 'bilibili') {
|
|
151
|
+
return `https://player.bilibili.com/player.html?bvid=${encodeURIComponent(media.ref)}&t=${String(t)}&danmaku=0&high_quality=1&autoplay=${t > 0 ? '1' : '0'}`;
|
|
152
|
+
}
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* A link to the original at a moment (what notes and canvas cards carry).
|
|
157
|
+
*
|
|
158
|
+
* @param link - the entry's link.
|
|
159
|
+
* @param seconds - the moment.
|
|
160
|
+
*/
|
|
161
|
+
export function linkAt(link, seconds) {
|
|
162
|
+
const media = mediaLink(link);
|
|
163
|
+
const t = Math.max(0, Math.floor(seconds));
|
|
164
|
+
if (media?.provider === 'youtube' || media?.provider === 'bilibili') {
|
|
165
|
+
try {
|
|
166
|
+
const url = new URL(link);
|
|
167
|
+
url.searchParams.set('t', media.provider === 'youtube' ? `${String(t)}s` : String(t));
|
|
168
|
+
return url.toString();
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return link;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return link;
|
|
175
|
+
}
|
|
176
|
+
/** The `<meta name>` a media link's synthetic page carries its description in. */
|
|
177
|
+
export const MEDIA_META_NAME = 'dsh-reader:media';
|
|
178
|
+
/** Decode the few entities an attribute value carries. */
|
|
179
|
+
function unescapeAttribute(value) {
|
|
180
|
+
return value.replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/'/g, "'").replace(/&/g, '&');
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* The media description a synthetic page carries (see `media-host.ts`).
|
|
184
|
+
*
|
|
185
|
+
* @param raw - a stored link payload.
|
|
186
|
+
* @returns the media, or `undefined` for an ordinary page.
|
|
187
|
+
*/
|
|
188
|
+
export function readMediaMeta(raw) {
|
|
189
|
+
const match = new RegExp(`<meta name="${MEDIA_META_NAME}" content="([^"]*)"`).exec(raw);
|
|
190
|
+
if (match === null)
|
|
191
|
+
return undefined;
|
|
192
|
+
try {
|
|
193
|
+
const value = JSON.parse(unescapeAttribute(match[1]));
|
|
194
|
+
if ((value.kind !== 'video' && value.kind !== 'audio') || typeof value.ref !== 'string' || typeof value.provider !== 'string')
|
|
195
|
+
return undefined;
|
|
196
|
+
return {
|
|
197
|
+
kind: value.kind,
|
|
198
|
+
provider: value.provider,
|
|
199
|
+
ref: value.ref,
|
|
200
|
+
...(typeof value.durationSec === 'number' ? { durationSec: value.durationSec } : {}),
|
|
201
|
+
...(typeof value.thumbnail === 'string' ? { thumbnail: value.thumbnail } : {}),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
}
|