diff options
author | Profpatsch <mail@profpatsch.de> | 2024-10-01T17·10+0200 |
---|---|---|
committer | Profpatsch <mail@profpatsch.de> | 2024-10-05T13·49+0000 |
commit | cf68a34b0d882374a02aa418eccae4f588483094 (patch) | |
tree | 0d9a0ffd8674b47edba2cc6d4dc52eae7a5b6ece /users | |
parent | ad711b15a06be8c3f6146f14454621f4a56997ba (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
Diffstat (limited to 'users')
-rw-r--r-- | users/Profpatsch/lyric/extension/package.json | 15 | ||||
-rw-r--r-- | users/Profpatsch/lyric/extension/src/extension.ts | 282 | ||||
-rw-r--r-- | users/Profpatsch/lyric/extension/src/upload-lrc.ts | 114 |
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; + } +} |