about summary refs log tree commit diff
diff options
context:
space:
mode:
authorProfpatsch <mail@profpatsch.de>2024-10-01T17·10+0200
committerProfpatsch <mail@profpatsch.de>2024-10-05T13·49+0000
commitcf68a34b0d882374a02aa418eccae4f588483094 (patch)
tree0d9a0ffd8674b47edba2cc6d4dc52eae7a5b6ece
parentad711b15a06be8c3f6146f14454621f4a56997ba (diff)
feat(users/Profpatsch/lyric/ext): add lrc upload & ms offset r/8767
This adds support for uploading the lyrics part of an .lrc file to
lrclib, see https://lrclib.net/docs

I pretty much only used ChatGPT to translate the rust “proof of work”
challenge to nodejs and it worked first try lol.

Before uploading the lyrics, I construct a webview with a preview of
what is going to be uploaded, and then only upload when that is
accepted. Pretty sweet.

Also adds two commands for increasing/decreasing the current timestamp
by 100ms and starting playback from 2 seconds before that, very handy
for fine-tuning lines.

Change-Id: Ia6adfe26d0c21c62554c8f8c55e97e2caec95d1e
Reviewed-on: https://cl.tvl.fyi/c/depot/+/12561
Reviewed-by: Profpatsch <mail@profpatsch.de>
Tested-by: BuildkiteCI
-rw-r--r--users/Profpatsch/lyric/extension/package.json15
-rw-r--r--users/Profpatsch/lyric/extension/src/extension.ts282
-rw-r--r--users/Profpatsch/lyric/extension/src/upload-lrc.ts114
3 files changed, 405 insertions, 6 deletions
diff --git a/users/Profpatsch/lyric/extension/package.json b/users/Profpatsch/lyric/extension/package.json
index 76fd8d63e73d..78e2eb1c3cf6 100644
--- a/users/Profpatsch/lyric/extension/package.json
+++ b/users/Profpatsch/lyric/extension/package.json
@@ -34,6 +34,21 @@
         "command": "extension.quantizeToEigthNote",
         "title": "Quantize timestamps to nearest eighth note",
         "category": "LRC"
+      },
+      {
+        "command": "extension.fineTuneTimestampDown100MsAndPlay",
+        "title": "Remove 100 ms from current timestamp and play from shortly before the change",
+        "category": "LRC"
+      },
+      {
+        "command": "extension.fineTuneTimestampUp100MsAndPlay",
+        "title": "Add 100 ms to current timestamp and play from shortly before the change",
+        "category": "LRC"
+      },
+      {
+        "command": "extension.uploadLyricsToLrclibDotNet",
+        "title": "Upload Lyrics to lrclib.net",
+        "category": "LRC"
       }
     ],
     "languages": [
diff --git a/users/Profpatsch/lyric/extension/src/extension.ts b/users/Profpatsch/lyric/extension/src/extension.ts
index a895bd57bf2a..55b8f9e49e3d 100644
--- a/users/Profpatsch/lyric/extension/src/extension.ts
+++ b/users/Profpatsch/lyric/extension/src/extension.ts
@@ -1,6 +1,9 @@
 import * as vscode from 'vscode';
 import * as net from 'net';
 import { adjustTimestampToEighthNote, bpmToEighthNoteDuration } from './quantize-lrc';
+import { publishLyrics, PublishRequest } from './upload-lrc';
+
+const channel_global = vscode.window.createOutputChannel('LRC');
 
 export function activate(context: vscode.ExtensionContext) {
   context.subscriptions.push(...registerCheckLineTimestamp(context));
@@ -8,7 +11,19 @@ export function activate(context: vscode.ExtensionContext) {
     vscode.commands.registerCommand('extension.jumpToLrcPosition', jumpToLrcPosition),
     vscode.commands.registerCommand('extension.shiftLyricsDown', shiftLyricsDown),
     vscode.commands.registerCommand('extension.shiftLyricsUp', shiftLyricsUp),
-    vscode.commands.registerCommand('extension.quantizeToEigthNote', quantizeLrc),
+    vscode.commands.registerCommand('extension.quantizeToEigthNote', quantizeToEigthNote),
+    vscode.commands.registerCommand(
+      'extension.fineTuneTimestampDown100MsAndPlay',
+      fineTuneTimestampAndPlay(-100),
+    ),
+    vscode.commands.registerCommand(
+      'extension.fineTuneTimestampUp100MsAndPlay',
+      fineTuneTimestampAndPlay(100),
+    ),
+    vscode.commands.registerCommand(
+      'extension.uploadLyricsToLrclibDotNet',
+      uploadToLrclibDotNet,
+    ),
   );
 }
 
@@ -39,18 +54,23 @@ function jumpToLrcPosition() {
   const { milliseconds, seconds } = res;
 
   // Prepare JSON command to send to the socket
-  const jsonCommand = {
+  const seekCommand = {
     command: ['seek', seconds, 'absolute'],
   };
+  const reloadSubtitlesCommand = {
+    command: ['sub-reload'],
+  };
 
   const socket = new net.Socket();
 
   const socketPath = process.env.HOME + '/tmp/mpv-socket';
   socket.connect(socketPath, () => {
-    socket.write(JSON.stringify(jsonCommand));
+    socket.write(JSON.stringify(seekCommand));
+    socket.write('\n');
+    socket.write(JSON.stringify(reloadSubtitlesCommand));
     socket.write('\n');
     vscode.window.showInformationMessage(
-      `Sent command to jump to [${formatTimestamp(milliseconds)}].`,
+      `Sent command to jump to [${formatTimestamp(milliseconds)}] and sync subtitles.`,
     );
     socket.end();
   });
@@ -163,7 +183,7 @@ async function shiftLyricsUp() {
 }
 
 /** first ask the user for the BPM of the track, then quantize the timestamps in the active text editor to the closest eighth note based on the given BPM */
-async function quantizeLrc() {
+async function quantizeToEigthNote() {
   const editor = vscode.window.activeTextEditor;
 
   if (!editor) {
@@ -224,6 +244,62 @@ async function quantizeLrc() {
   });
 }
 
+/** fine tune the timestamp of the current line by the given amount (in milliseconds) and play the track at slightly before the new timestamp */
+function fineTuneTimestampAndPlay(amountMs: number) {
+  return async () => {
+    const editor = vscode.window.activeTextEditor;
+
+    if (!editor) {
+      vscode.window.showInformationMessage('No active editor found.');
+      return;
+    }
+
+    const ext = new Ext(editor.document);
+
+    const position = editor.selection.active;
+    const res = ext.getTimestampFromLine(position.line);
+
+    if (!res) {
+      return;
+    }
+    const { milliseconds } = res;
+
+    const newMs = milliseconds + amountMs;
+
+    // adjust the timestamp
+    const documentRange = editor.document.lineAt(position.line).range;
+    await editor.edit(editBuilder => {
+      editBuilder.replace(documentRange, `[${formatTimestamp(newMs)}]${res.text}`);
+    });
+
+    const PLAY_BEFORE_TIMESTAMP_MS = 2000;
+    const seekCommand = {
+      command: ['seek', (newMs - PLAY_BEFORE_TIMESTAMP_MS) / 1000, 'absolute'],
+    };
+    const reloadSubtitlesCommand = {
+      command: ['sub-reload'],
+    };
+
+    const socket = new net.Socket();
+
+    const socketPath = process.env.HOME + '/tmp/mpv-socket';
+    socket.connect(socketPath, () => {
+      socket.write(JSON.stringify(seekCommand));
+      socket.write('\n');
+      socket.write(JSON.stringify(reloadSubtitlesCommand));
+      socket.write('\n');
+      vscode.window.showInformationMessage(
+        `Sent command to jump to [${formatTimestamp(newMs)}] and sync subtitles.`,
+      );
+      socket.end();
+    });
+
+    socket.on('error', err => {
+      vscode.window.showErrorMessage(`Failed to send command: ${err.message}`);
+    });
+  };
+}
+
 // convert the given bpm to miliseconds
 function bpmToMs(bpm: number) {
   return Math.floor((60 / bpm) * 1000);
@@ -253,12 +329,14 @@ async function timeInputBpm(startBpm?: number) {
   };
 
   let lastPressTime = Date.now();
+  let firstLoop = true;
   while (true) {
     const res = await vscode.window.showInputBox({
       prompt: `Press enter to record BPM (current BPM: ${calculateBPM()}), enter the final BPM once you know, or press esc to finish`,
       placeHolder: 'BPM',
-      value: startBpm !== undefined ? startBpm.toString() : undefined,
+      value: startBpm !== undefined && firstLoop ? startBpm.toString() : undefined,
     });
+    firstLoop = false;
     if (res === undefined) {
       return undefined;
     }
@@ -281,6 +359,165 @@ async function timeInputBpm(startBpm?: number) {
   }
 }
 
+/**
+ * Uploads the lyrics in the active text editor to the LrclibDotNet API.
+ * @remarks
+ * This function requires the following dependencies:
+ * - `vscode` module for accessing the active text editor and displaying messages.
+ * - `fetch` module for making HTTP requests.
+ * @throws {Error} If there is an HTTP error.
+ */
+async function uploadToLrclibDotNet() {
+  const editor = vscode.window.activeTextEditor;
+
+  if (!editor) {
+    vscode.window.showInformationMessage('No active editor found.');
+    return;
+  }
+
+  const ext = new Ext(editor.document);
+
+  const title = ext.findHeader('ti')?.value;
+  const artist = ext.findHeader('ar')?.value;
+  const album = ext.findHeader('al')?.value;
+  const lengthString = ext.findHeader('length')?.value;
+
+  if (
+    title === undefined ||
+    artist === undefined ||
+    album === undefined ||
+    lengthString === undefined
+  ) {
+    vscode.window.showErrorMessage(
+      'Missing required headers: title, artist, album, length',
+    );
+    return;
+  }
+  // parse length as mm:ss
+  const [minutes, seconds] = lengthString?.split(':') ?? [];
+  if (
+    !minutes ||
+    !seconds ||
+    isNaN(parseInt(minutes, 10)) ||
+    isNaN(parseInt(seconds, 10))
+  ) {
+    vscode.window.showErrorMessage('Invalid length header, expected format: mm:ss');
+    return;
+  }
+  const length = parseInt(minutes, 10) * 60 + parseInt(seconds, 10);
+
+  const syncedLyrics = ext.getLyricsPart();
+  const plainLyrics = plainLyricsFromLyrics(syncedLyrics);
+
+  // open a html preview with the lyrics saying
+  //
+  // Uploading these lyrics to lrclib.net:
+  // <metadata as table>
+  // Lyrics:
+  // ```<lyrics>```
+  // Plain lyrics:
+  // ```<plainLyrics>```
+  //
+  // Is this ok?
+  // <button to upload>
+
+  const previewTitle = 'Lyric Preview';
+  const metadataTable = `
+  <table>
+    <tr>
+      <th>Title</th>
+      <td>${title}</td>
+    </tr>
+    <tr>
+      <th>Artist</th>
+      <td>${artist}</td>
+    </tr>
+    <tr>
+      <th>Album</th>
+      <td>${album}</td>
+    </tr>
+    <tr>
+      <th>Length</th>
+      <td>${lengthString}</td>
+    </tr>
+  </table>
+  `;
+  const previewContent = `
+    <p>Uploading these lyrics to lrclib.net:</p>
+    ${metadataTable}
+    <p>Lyrics:</p>
+    <pre>${syncedLyrics}</pre>
+    <p>Plain lyrics:</p>
+    <pre>${plainLyrics}</pre>
+    <p>Is this ok?</p>
+    <button>Upload</button>
+  `;
+
+  const panel = vscode.window.createWebviewPanel(
+    'lyricPreview',
+    previewTitle,
+    vscode.ViewColumn.One,
+    { enableScripts: true },
+  );
+  panel.webview.html = `
+      <!DOCTYPE html>
+    <html lang="en">
+    <head>
+      <meta charset="UTF-8">
+      <meta name="viewport" content="width=device-width, initial-scale=1.0">
+      <title>Markdown Preview</title>
+    </head>
+    <body>
+      ${previewContent}
+    </body>
+    <script>
+      const vscode = acquireVsCodeApi();
+      document.querySelector('button').addEventListener('click', () => {
+        vscode.postMessage({ command: 'upload' });
+      });
+    </script>
+    </html>
+  `;
+  let isDisposed = false;
+  panel.onDidDispose(() => {
+    isDisposed = true;
+  });
+
+  await new Promise((resolve, _reject) => {
+    panel.webview.onDidReceiveMessage((message: { command: string }) => {
+      if (isDisposed) {
+        return;
+      }
+      if (message.command === 'upload') {
+        panel.dispose();
+        resolve(true);
+      }
+    });
+  });
+
+  const toUpload: PublishRequest = {
+    trackName: title,
+    artistName: artist,
+    albumName: album,
+    duration: length,
+    plainLyrics: plainLyrics,
+    syncedLyrics: syncedLyrics,
+  };
+
+  // log the data to our extension output buffer
+  channel_global.appendLine('Uploading lyrics to LrclibDotNet');
+  const json = JSON.stringify(toUpload, null, 2);
+  channel_global.appendLine(json);
+
+  const res = await publishLyrics(toUpload);
+
+  if (res) {
+    vscode.window.showInformationMessage('Lyrics successfully uploaded.');
+  } else {
+    vscode.window.showErrorMessage('Failed to upload lyrics.');
+  }
+}
+
 // If the difference to the timestamp on the next line is larger than 10 seconds, underline the next line and show a warning message on hover
 export function registerCheckLineTimestamp(_context: vscode.ExtensionContext) {
   const changesToCheck: Set<vscode.TextDocument> = new Set();
@@ -487,6 +724,29 @@ class Ext {
       });
     }
   }
+
+  // get the lyrics part of the lrc file (after the headers)
+  getLyricsPart() {
+    const first = this.document.lineAt(0).text;
+    let line = 0;
+    if (this.isHeaderLine(first)) {
+      // skip all headers (until the first empty line)
+      for (; line < this.document.lineCount; line++) {
+        const text = this.document.lineAt(line).text;
+        if (text.trim() === '') {
+          line++;
+          break;
+        }
+      }
+    }
+
+    // get the range from the current line to the end of the file
+    const documentRange = new vscode.Range(
+      new vscode.Position(line, 0),
+      this.document.lineAt(this.document.lineCount - 1).range.end,
+    );
+    return this.document.getText(documentRange);
+  }
 }
 
 // find an active editor that has the given document opened
@@ -494,6 +754,16 @@ function findActiveEditor(document: vscode.TextDocument) {
   return vscode.window.visibleTextEditors.find(editor => editor.document === document);
 }
 
+function plainLyricsFromLyrics(lyrics: string) {
+  // remove the timestamp from the beginning of every line
+  return (
+    lyrics
+      .replace(/\[\d\d:\d\d\.\d\d\]\s?/gm, '')
+      // remove empty lines
+      .replace(/\n{2,}/g, '\n')
+  );
+}
+
 function parseTimestamp(timestamp: string): number {
   // Parse [mm:ss.ms] format into milliseconds
   const [min, sec] = timestamp.split(':');
diff --git a/users/Profpatsch/lyric/extension/src/upload-lrc.ts b/users/Profpatsch/lyric/extension/src/upload-lrc.ts
new file mode 100644
index 000000000000..6686483847d8
--- /dev/null
+++ b/users/Profpatsch/lyric/extension/src/upload-lrc.ts
@@ -0,0 +1,114 @@
+import * as crypto from 'crypto';
+
+// Helper function to convert a hex string to a Buffer
+function hexToBytes(hex: string): Buffer {
+  return Buffer.from(hex, 'hex');
+}
+
+// Function to verify the nonce
+function verifyNonce(result: Buffer, target: Buffer): boolean {
+  if (result.length !== target.length) {
+    return false;
+  }
+
+  for (let i = 0; i < result.length - 1; i++) {
+    if (result[i] > target[i]) {
+      return false;
+    } else if (result[i] < target[i]) {
+      break;
+    }
+  }
+
+  return true;
+}
+
+// Function to solve the challenge
+function solveChallenge(prefix: string, targetHex: string): string {
+  let nonce = 0;
+  let hashed: Buffer;
+  const target = hexToBytes(targetHex);
+
+  while (true) {
+    const input = `${prefix}${nonce}`;
+    hashed = crypto.createHash('sha256').update(input).digest();
+
+    if (verifyNonce(hashed, target)) {
+      break;
+    } else {
+      nonce += 1;
+    }
+  }
+
+  return nonce.toString();
+}
+
+async function getUploadNonce() {
+  try {
+    const response = await fetch('https://lrclib.net/api/request-challenge', {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        'User-Agent': 'lyric tool (https://code.tvl.fyi/tree/users/Profpatsch/lyric)',
+        'Lrclib-Client': 'lyric tool (https://code.tvl.fyi/tree/users/Profpatsch/lyric)',
+      },
+    });
+
+    if (!response.ok) {
+      throw new Error(`HTTP error! Status: ${response.status}`);
+    }
+
+    const challengeData = (await response.json()) as { prefix: string; target: string };
+
+    return {
+      prefix: challengeData.prefix,
+      nonce: solveChallenge(challengeData.prefix, challengeData.target),
+    };
+  } catch (error) {
+    console.error('Error fetching the challenge:', error);
+  }
+}
+
+// Interface for the request body
+/**
+ * Represents a request to publish a track with its associated information.
+ */
+export interface PublishRequest {
+  trackName: string;
+  artistName: string;
+  albumName: string;
+  /** In seconds? Milliseconds? mm:ss? */
+  duration: number;
+  plainLyrics: string;
+  syncedLyrics: string;
+}
+
+// Function to publish lyrics using the solved challenge
+export async function publishLyrics(
+  requestBody: PublishRequest,
+): Promise<true | undefined> {
+  const challenge = await getUploadNonce();
+  if (!challenge) {
+    return;
+  }
+  const publishToken = `${challenge.prefix}:${challenge.nonce}`;
+
+  const response = await fetch('https://lrclib.net/api/publish', {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      'User-Agent': 'lyric tool (https://code.tvl.fyi/tree/users/Profpatsch/lyric)',
+      'Lrclib-Client': 'lyric tool (https://code.tvl.fyi/tree/users/Profpatsch/lyric)',
+      'X-Publish-Token': publishToken,
+    },
+    body: JSON.stringify(requestBody),
+  });
+
+  if (response.status === 201) {
+    console.log('Lyrics successfully published.');
+    return true;
+  } else {
+    const errorResponse = (await response.json()) as { [key: string]: string };
+    console.error('Failed to publish lyrics:', errorResponse);
+    return;
+  }
+}