feat(users/Profpatsch/lyric/ext): add lrc upload & ms offset
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
This commit is contained in:
parent
ad711b15a0
commit
cf68a34b0d
3 changed files with 405 additions and 6 deletions
|
@ -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": [
|
||||
|
|
|
@ -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(':');
|
||||
|
|
114
users/Profpatsch/lyric/extension/src/upload-lrc.ts
Normal file
114
users/Profpatsch/lyric/extension/src/upload-lrc.ts
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue