|
|
@@ -274,6 +274,16 @@ export class PlexApi {
|
|
|
return `${baseUrl}${normalizedPath}${joiner}X-Plex-Token=${encodeURIComponent(auth.token)}`;
|
|
|
}
|
|
|
|
|
|
+ async getLyric(account: WebDavAccount, songId: string): Promise<string> {
|
|
|
+ if (!songId) {
|
|
|
+ return '';
|
|
|
+ }
|
|
|
+ const xml = await this.getXml(account, `/library/streams/${songId}`, [
|
|
|
+ new QueryParam('format', 'xml')
|
|
|
+ ]);
|
|
|
+ return this.parseLyrics(xml);
|
|
|
+ }
|
|
|
+
|
|
|
private async ensureMusicSectionId(account: WebDavAccount): Promise<string> {
|
|
|
const auth = await this.ensureAuth(account);
|
|
|
if (auth.musicSectionId) {
|
|
|
@@ -565,6 +575,99 @@ export class PlexApi {
|
|
|
return results;
|
|
|
}
|
|
|
|
|
|
+ private parseLyrics(xmlText: string): string {
|
|
|
+ const blocks: string[] = [];
|
|
|
+ const lyricsTag = /<Lyrics\b[^>]*>([\s\S]*?)<\/Lyrics>/g;
|
|
|
+ let lyricsMatch = lyricsTag.exec(xmlText);
|
|
|
+ while (lyricsMatch) {
|
|
|
+ const block = lyricsMatch[1] ?? '';
|
|
|
+ const lines = this.parseLyricLines(block);
|
|
|
+ if (lines.length > 0) {
|
|
|
+ blocks.push(lines.join('\n'));
|
|
|
+ }
|
|
|
+ lyricsMatch = lyricsTag.exec(xmlText);
|
|
|
+ }
|
|
|
+ return blocks.join('\n').trim();
|
|
|
+ }
|
|
|
+
|
|
|
+ private parseLyricLines(block: string): string[] {
|
|
|
+ const lines: string[] = [];
|
|
|
+ const lineTag = /<Line\b([^>]*)>([\s\S]*?)<\/Line>/g;
|
|
|
+ let lineMatch = lineTag.exec(block);
|
|
|
+ while (lineMatch) {
|
|
|
+ const attrs = this.parseAttributes(lineMatch[1]);
|
|
|
+ const text = this.parseLyricLineText(lineMatch[2] ?? '', attrs);
|
|
|
+ const timeTag = this.formatLyricTime(attrs.time ?? attrs.start ?? attrs.begin);
|
|
|
+ if (text) {
|
|
|
+ lines.push(timeTag ? `${timeTag}${text}` : text);
|
|
|
+ }
|
|
|
+ lineMatch = lineTag.exec(block);
|
|
|
+ }
|
|
|
+ const selfClosing = /<Line\b([^>]*)\/>/g;
|
|
|
+ let selfMatch = selfClosing.exec(block);
|
|
|
+ while (selfMatch) {
|
|
|
+ const attrs = this.parseAttributes(selfMatch[1]);
|
|
|
+ const text = this.parseLyricLineText('', attrs);
|
|
|
+ const timeTag = this.formatLyricTime(attrs.time ?? attrs.start ?? attrs.begin);
|
|
|
+ if (text) {
|
|
|
+ lines.push(timeTag ? `${timeTag}${text}` : text);
|
|
|
+ }
|
|
|
+ selfMatch = selfClosing.exec(block);
|
|
|
+ }
|
|
|
+ return lines;
|
|
|
+ }
|
|
|
+
|
|
|
+ private parseLyricLineText(inner: string, lineAttrs: Record<string, string>): string {
|
|
|
+ const parts: string[] = [];
|
|
|
+ const spanTag = /<Span\b([^>]*)\/>/g;
|
|
|
+ let spanMatch = spanTag.exec(inner);
|
|
|
+ while (spanMatch) {
|
|
|
+ const spanAttrs = this.parseAttributes(spanMatch[1]);
|
|
|
+ if (spanAttrs.text) {
|
|
|
+ parts.push(spanAttrs.text);
|
|
|
+ }
|
|
|
+ spanMatch = spanTag.exec(inner);
|
|
|
+ }
|
|
|
+ const fullSpanTag = /<Span\b([^>]*)>([\s\S]*?)<\/Span>/g;
|
|
|
+ let fullMatch = fullSpanTag.exec(inner);
|
|
|
+ while (fullMatch) {
|
|
|
+ const spanAttrs = this.parseAttributes(fullMatch[1]);
|
|
|
+ const text = spanAttrs.text ?? this.decodeXmlEntities(fullMatch[2] ?? '');
|
|
|
+ if (text) {
|
|
|
+ parts.push(text);
|
|
|
+ }
|
|
|
+ fullMatch = fullSpanTag.exec(inner);
|
|
|
+ }
|
|
|
+ if (parts.length > 0) {
|
|
|
+ return parts.join('');
|
|
|
+ }
|
|
|
+ if (lineAttrs.text) {
|
|
|
+ return lineAttrs.text;
|
|
|
+ }
|
|
|
+ return '';
|
|
|
+ }
|
|
|
+
|
|
|
+ private formatLyricTime(raw?: string): string | undefined {
|
|
|
+ if (!raw) {
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+ const value = Number(raw);
|
|
|
+ if (!Number.isFinite(value)) {
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+ let totalMs = value;
|
|
|
+ if (value < 1000) {
|
|
|
+ totalMs = Math.round(value * 1000);
|
|
|
+ }
|
|
|
+ const minutes = Math.floor(totalMs / 60000);
|
|
|
+ const seconds = Math.floor((totalMs % 60000) / 1000);
|
|
|
+ const centiseconds = Math.floor((totalMs % 1000) / 10);
|
|
|
+ const mm = minutes.toString().padStart(2, '0');
|
|
|
+ const ss = seconds.toString().padStart(2, '0');
|
|
|
+ const cc = centiseconds.toString().padStart(2, '0');
|
|
|
+ return `[${mm}:${ss}.${cc}]`;
|
|
|
+ }
|
|
|
+
|
|
|
private extractTagAttributes(xmlText: string, tagName: string): Record<string, string> {
|
|
|
const tagMatch = xmlText.match(new RegExp(`<${tagName}\\b([^>]*)\\/?>`));
|
|
|
if (!tagMatch) {
|