Skip to content

Commit 602a747

Browse files
committed
add: implement letrasmus source and related functionality
1 parent 5a99e4d commit 602a747

5 files changed

Lines changed: 1050 additions & 0 deletions

File tree

config.default.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,9 @@ export default {
346346
lastfm: {
347347
enabled: true
348348
},
349+
letrasmus: {
350+
enabled: true
351+
},
349352
yandexmusic: {
350353
enabled: true,
351354
accessToken: '',
@@ -371,6 +374,9 @@ export default {
371374
lrclib: {
372375
enabled: true
373376
},
377+
letrasmus: {
378+
enabled: true
379+
},
374380
bilibili: {
375381
enabled: true
376382
},

src/api/meaning.js

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import myzod from 'myzod'
2+
import { translateMany, translateText } from '../modules/googleTranslate.js'
3+
import {
4+
decodeTrack,
5+
http1makeRequest,
6+
logger,
7+
sendErrorResponse
8+
} from '../utils.js'
9+
10+
const meaningSchema = myzod.object({
11+
encodedTrack: myzod.string(),
12+
lang: myzod.string().optional()
13+
})
14+
15+
const decodeHtml = (text) => {
16+
if (!text) return text
17+
return text
18+
.replace(/&/g, '&')
19+
.replace(/"/g, '"')
20+
.replace(/'/g, "'")
21+
.replace(/'/g, "'")
22+
.replace(/&lt;/g, '<')
23+
.replace(/&gt;/g, '>')
24+
}
25+
26+
const extractMeta = (html, property) => {
27+
const re1 = new RegExp(
28+
`<meta[^>]+property=[\"']${property}[\"'][^>]+content=[\"']([^\"']+)[\"'][^>]*>`,
29+
'i'
30+
)
31+
const re2 = new RegExp(
32+
`<meta[^>]+content=[\"']([^\"']+)[^>]+property=[\"']${property}[\"'][^>]*>`,
33+
'i'
34+
)
35+
const match = html.match(re1) || html.match(re2)
36+
return match ? decodeHtml(match[1]) : null
37+
}
38+
39+
const extractOmqLyric = (html) => {
40+
const match = html.match(/_omq\.push\(\['ui\/lyric',\s*({[\s\S]*?})\s*,/i)
41+
if (!match) return null
42+
try {
43+
return JSON.parse(match[1])
44+
} catch {
45+
return null
46+
}
47+
}
48+
49+
const extractOmqMeaning = (html) => {
50+
const match = html.match(
51+
/_omq\.push\(\['ui\/lyric',\s*({[\s\S]*?})\s*,\s*({[\s\S]*?})\s*,/i
52+
)
53+
if (!match) return null
54+
try {
55+
return JSON.parse(match[2])
56+
} catch {
57+
return null
58+
}
59+
}
60+
61+
const extractMeaning = (html) => {
62+
const match = html.match(/<div class="lyric-meaning[^>]*">([\s\S]*?)<\/div>/i)
63+
if (!match) return { title: null, body: [] }
64+
let block = match[1]
65+
const titleMatch = block.match(/<h3[^>]*>([\s\S]*?)<\/h3>/i)
66+
const title = titleMatch ? decodeHtml(titleMatch[1].replace(/<[^>]+>/g, '')) : null
67+
block = block.replace(/<h3[^>]*>[\s\S]*?<\/h3>/i, '')
68+
const paragraphs = []
69+
const pRegex = /<p[^>]*>([\s\S]*?)<\/p>/gi
70+
let pMatch
71+
while ((pMatch = pRegex.exec(block))) {
72+
let text = pMatch[1]
73+
text = text.replace(/<br\s*\/?>/gi, '\n')
74+
text = text.replace(/<[^>]+>/g, '')
75+
text = decodeHtml(text)
76+
const lines = text
77+
.split('\n')
78+
.map((line) => line.trim())
79+
.filter(Boolean)
80+
if (lines.length) paragraphs.push(lines.join(' '))
81+
}
82+
if (!paragraphs.length) {
83+
let text = block.replace(/<br\s*\/?>/gi, '\n')
84+
text = text.replace(/<[^>]+>/g, '')
85+
text = decodeHtml(text)
86+
const lines = text
87+
.split('\n')
88+
.map((line) => line.trim())
89+
.filter(Boolean)
90+
if (lines.length) paragraphs.push(lines.join(' '))
91+
}
92+
return { title, body: paragraphs }
93+
}
94+
95+
async function handler(nodelink, req, res, sendResponse, parsedUrl) {
96+
const result = meaningSchema.try({
97+
encodedTrack: parsedUrl.searchParams.get('encodedTrack'),
98+
lang: parsedUrl.searchParams.get('lang') || 'en'
99+
})
100+
101+
if (result instanceof myzod.ValidationError) {
102+
const errorMessage = result.message || 'encodedTrack parameter is required.'
103+
logger('warn', 'Meaning', errorMessage)
104+
return sendErrorResponse(
105+
req,
106+
res,
107+
400,
108+
'missing encodedTrack parameter',
109+
errorMessage,
110+
parsedUrl.pathname,
111+
true
112+
)
113+
}
114+
115+
let decodedTrack
116+
const targetLang = result.lang
117+
try {
118+
decodedTrack = decodeTrack(result.encodedTrack.replace(/ /g, '+'))
119+
} catch (err) {
120+
logger('warn', 'Meaning', `Invalid encoded track: ${err.message}`)
121+
return sendErrorResponse(
122+
req,
123+
res,
124+
400,
125+
'invalid encodedTrack',
126+
err.message,
127+
parsedUrl.pathname,
128+
true
129+
)
130+
}
131+
132+
try {
133+
let trackInfo = decodedTrack.info
134+
if (nodelink.sources?.resolve && decodedTrack.info?.uri) {
135+
const resolved = await nodelink.sources.resolve(decodedTrack.info.uri)
136+
if (resolved.loadType !== 'track') {
137+
return sendResponse(
138+
req,
139+
res,
140+
{ loadType: 'empty', data: {} },
141+
200
142+
)
143+
}
144+
trackInfo = resolved.data?.info || resolved.data
145+
}
146+
147+
if (!trackInfo || trackInfo.sourceName !== 'letrasmus') {
148+
return sendResponse(
149+
req,
150+
res,
151+
{ loadType: 'empty', data: {} },
152+
200
153+
)
154+
}
155+
156+
const baseUrl = trackInfo.uri?.endsWith('/')
157+
? trackInfo.uri
158+
: `${trackInfo.uri}/`
159+
const meaningUrl = `${baseUrl}significado.html`
160+
const { body, statusCode, error } = await http1makeRequest(meaningUrl, {
161+
method: 'GET'
162+
}).catch((e) => ({ error: e }))
163+
164+
if (error || statusCode !== 200 || !body) {
165+
return sendResponse(
166+
req,
167+
res,
168+
{ loadType: 'empty', data: {} },
169+
200
170+
)
171+
}
172+
173+
const meaning = extractMeaning(body)
174+
const omq = extractOmqLyric(body)
175+
const meaningMeta = extractOmqMeaning(body)
176+
const ogImage = extractMeta(body, 'og:image')
177+
const ogTitle = extractMeta(body, 'og:title')
178+
const ogDescription = extractMeta(body, 'og:description')
179+
180+
let translated = null
181+
if (targetLang) {
182+
const sourceLang = 'pt'
183+
try {
184+
const translatedParagraphs = await translateMany(
185+
meaning.body,
186+
sourceLang,
187+
targetLang
188+
)
189+
const translatedTitle = meaning.title
190+
? await translateText(meaning.title, sourceLang, targetLang)
191+
: null
192+
const translatedDescription = ogDescription
193+
? await translateText(ogDescription, sourceLang, targetLang)
194+
: null
195+
translated = {
196+
language: {
197+
source: sourceLang,
198+
target: targetLang
199+
},
200+
title: translatedTitle?.translation || null,
201+
description: translatedDescription?.translation || null,
202+
paragraphs: translatedParagraphs
203+
}
204+
} catch (e) {
205+
logger('warn', 'Meaning', `Translate failed: ${e.message}`)
206+
}
207+
}
208+
209+
return sendResponse(req, res, {
210+
loadType: meaning.body.length ? 'meaning' : 'empty',
211+
data: {
212+
title: meaning.title || ogTitle || null,
213+
description: ogDescription || null,
214+
paragraphs: meaning.body,
215+
translation: translated,
216+
url: meaningUrl,
217+
meaningMeta: {
218+
id: meaningMeta?.ID || null,
219+
localeId: meaningMeta?.LocaleID || null,
220+
origin: meaningMeta?.Origin || null,
221+
submittedBy: null,
222+
reviewedBy: null
223+
},
224+
song: {
225+
title: omq?.Name || trackInfo.title || null,
226+
artist: omq?.Artist || trackInfo.author || null,
227+
youtubeId: omq?.YoutubeID || null,
228+
letrasId: omq?.ID || null,
229+
artworkUrl: ogImage || trackInfo.artworkUrl || null
230+
}
231+
}
232+
}, 200)
233+
} catch (err) {
234+
logger('error', 'Meaning', `Failed to load meaning: ${err.message}`)
235+
return sendErrorResponse(
236+
req,
237+
res,
238+
500,
239+
'failed to load meaning',
240+
err.message || 'Failed to load meaning',
241+
parsedUrl.pathname,
242+
true
243+
)
244+
}
245+
}
246+
247+
export default {
248+
handler
249+
}

0 commit comments

Comments
 (0)