From fe37c8aa6e701a51d9db93a15dd4e1b98661fa45 Mon Sep 17 00:00:00 2001 From: Eclypsed Date: Sat, 24 Feb 2024 02:06:02 -0500 Subject: [PATCH 1/2] I can't believe I figured out how to emulate ytmusic api calls --- package-lock.json | 62 ++++++++------ src/lib/server/users.db | Bin 0 -> 24576 bytes src/lib/server/users.ts | 10 +-- src/lib/services.json | 14 ++++ src/lib/services.ts | 79 ++++++++++++++---- src/routes/(app)/+page.svelte | 2 +- src/routes/api/connections/+server.ts | 1 + .../users/[userId]/recommendations/+server.ts | 6 +- .../settings/connections/+page.server.ts | 24 ++++-- src/routes/settings/connections/+page.svelte | 13 ++- .../connections/connectionProfile.svelte | 19 +++-- vite.config.ts | 8 +- 12 files changed, 166 insertions(+), 72 deletions(-) create mode 100644 src/lib/services.json diff --git a/package-lock.json b/package-lock.json index a9fb61f..c39790f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1145,14 +1145,15 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" }, "node_modules/call-bind": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.6.tgz", - "integrity": "sha512-Mj50FLHtlsoVfRfnHaZvyrooHcrlceNZdL/QBvJJVd9Ta55qCQK0gs4ss2oZDeV9zFCs6ewzYgVE5yfVmfFpVg==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", "dependencies": { + "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.3", - "set-function-length": "^1.2.0" + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" }, "engines": { "node": ">= 0.4" @@ -1373,17 +1374,19 @@ } }, "node_modules/define-data-property": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.2.tgz", - "integrity": "sha512-SRtsSqsDbgpJBbW3pABMCOt6rQyeM8s8RiyeSN8jYG8sYmt/kGJejbydttUsnDs1tadr19tvhT4ShwMyoqAm4g==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "dependencies": { + "es-define-property": "^1.0.0", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.2", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.1" + "gopd": "^1.0.1" }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/dequal": { @@ -1464,6 +1467,17 @@ "once": "^1.4.0" } }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", @@ -1658,9 +1672,9 @@ } }, "node_modules/gaxios": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.2.0.tgz", - "integrity": "sha512-H6+bHeoEAU5D6XNc6mPKeN5dLZqEDs9Gpk6I+SZBEzK5So58JVrHPmevNi35fRl1J9Y5TaeLW0kYx3pCJ1U2mQ==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.3.0.tgz", + "integrity": "sha512-p+ggrQw3fBwH2F5N/PAI4k/G/y1art5OxKpb2J2chwNNHM4hHuAOtivjPuirMF4KNKwTTUal/lPfL2+7h2mEcg==", "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", @@ -1862,20 +1876,20 @@ } }, "node_modules/has-property-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz", - "integrity": "sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "dependencies": { - "get-intrinsic": "^1.2.2" + "es-define-property": "^1.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/has-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", - "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", "engines": { "node": ">= 0.4" }, @@ -1906,9 +1920,9 @@ } }, "node_modules/https-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.2.tgz", - "integrity": "sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", + "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", "dependencies": { "agent-base": "^7.0.2", "debug": "4" diff --git a/src/lib/server/users.db b/src/lib/server/users.db index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..10018d8073b2a9e766c563c37417397ed92ae109 100644 GIT binary patch literal 24576 zcmeI)&u^Pn8~|{f#L1c|^?LGUm5>vuu~ZVkU_zxy!vy~}j_8E3E|M?*CCl)_=hb6WXzbAe@c=zv$X4xTr`% zOTt@1t}5Xh1;_h%lx!1k3yN42l;cb`jbA9GnxJTSMbcib%L5P1N)PME^%gFx)#_aZ zKb)cmO*|})4xifw!dp_e_4fhpC33ut9r?Nw)jCxBDBh^{I`G-Ro~-d&y)n_G5DGv~F;{*m_`d z*U_U@9DTA8IGM&(xztc4{9*i4-SZai%q;5O=mQf+r$1ENPp7wcliwvj7S;N!`t*9W zlivFGs?@sbc62>`b$d-6L~|_h3l>d~KmY_l00ck)1V8`;KmY_l00ck)1pX_5Ni42D zzm8KJIWRb65gBAL1d}y%f@e63V0nXQc#u%NMC(Tsc|_IZxp8r>_a>uY zTrB45r)^maIulBx#Mgzh<*dmKR+ZW+G)`A@9i6m$)5^(y6jv#0yZ?WWCEkB#0mA8m00@8p2!H?xfB*=900@8p z2!H?x{G-6v+s~iej~!T#|9^^M(F6$uKmY_l00ck)1V8`;KmY_l;ENTw`Qhd&wx_Bu zIzPoNGsBP>h9VeR&qND~A#^rt5(YWov+O`;&>8!fUz9^P@Ph#&CV^)gZ&Shg9|BUZ zQmV++31Os^&$7AlP~ineo;%uNn7VqK(hUQ7UhDSN|EOlD)BK4}3RbQ^2+vo*R))sIQ)fviaJO%?oSZ3p=*!6HS2(Crf@|_NJoc8)YWwF8LxmYS(Ay z)5~EaC;E~?6wJ$Jet|5uVduQ^j8r8z)0WXxR4>X4(dC?J#~IThkHTRuUz6sRH8Khf z-s;F@E?Zu9M@zvEN1RdOiMlQhwTfUeO+l$O^NnJmOV?~lAlqk?hNflFqA``qezv=u z4@runQZG|8WX+H_x{sAQJtf&*!57>D)v|OdYOf?Hf>03Dro6}#RlYQ9y^+;Ettn2u z;}?UxrZrT9wV4Xb3+k#c?TCDu-p}l|NmmY+z=QDfB*=900@8p2!H?xfB*=9!2dwtcKrWip6L7q D;Fykh literal 0 HcmV?d00001 diff --git a/src/lib/server/users.ts b/src/lib/server/users.ts index 0d9c1b6..1779bcd 100644 --- a/src/lib/server/users.ts +++ b/src/lib/server/users.ts @@ -13,7 +13,7 @@ const initConnectionsTable = `CREATE TABLE IF NOT EXISTS Connections( userId VARCHAR(36) NOT NULL, type VARCHAR(36) NOT NULL, service TEXT, - tokens TEXT + tokens TEXT, FOREIGN KEY(userId) REFERENCES Users(id) )` db.exec(initUsersTable), db.exec(initConnectionsTable) @@ -77,11 +77,11 @@ export class Connections { return connections } - static addConnection(type: T, connectionData: Omit, 'id' | 'type'>): DBConnectionData { + static addConnection(type: T, connectionData: Omit, 'id' | 'type'>): string { const connectionId = generateUUID() const { userId, service, tokens } = connectionData - db.prepare(`INSERT INTO Connections(id, userId, type, service, tokens) VALUES(?, ?, ?, ?, ?)`).run(connectionId, userId, type, service, tokens) - return this.getConnection(connectionId) as DBConnectionData + db.prepare(`INSERT INTO Connections(id, userId, type, service, tokens) VALUES(?, ?, ?, ?, ?)`).run(connectionId, userId, type, JSON.stringify(service), JSON.stringify(tokens)) + return connectionId } static deleteConnection = (id: string): void => { @@ -91,7 +91,7 @@ export class Connections { static updateTokens = (id: string, accessToken?: string, refreshToken?: string, expiry?: number): void => { const newTokens = { accessToken, refreshToken, expiry } - const commandInfo = db.prepare(`UPDATE Connections SET tokens = ? WHERE id = ?`).run(newTokens, id) + const commandInfo = db.prepare(`UPDATE Connections SET tokens = ? WHERE id = ?`).run(JSON.stringify(newTokens), id) if (commandInfo.changes === 0) throw new Error('Failed to update tokens') } diff --git a/src/lib/services.json b/src/lib/services.json new file mode 100644 index 0000000..c7b6d36 --- /dev/null +++ b/src/lib/services.json @@ -0,0 +1,14 @@ +{ + "jellyfin": { + "displayName": "Jellyfin", + "type": ["streaming"], + "icon": "https://raw.githubusercontent.com/jellyfin/jellyfin-ux/55616553b692b1a6c7d8e786eeb7d8216e9b50df/branding/SVG/icon-transparent.svg", + "primaryColor": "--jellyfin-blue" + }, + "youtube-music": { + "displayName": "YouTube Music", + "type": ["streaming"], + "icon": "https://upload.wikimedia.org/wikipedia/commons/6/6a/Youtube_Music_icon.svg", + "primaryColor": "--youtube-red" + } +} diff --git a/src/lib/services.ts b/src/lib/services.ts index a914a9b..2845321 100644 --- a/src/lib/services.ts +++ b/src/lib/services.ts @@ -1,20 +1,5 @@ import { google } from 'googleapis' -export const serviceData = { - jellyfin: { - displayName: 'Jellyfin', - type: ['streaming'], - icon: 'https://raw.githubusercontent.com/jellyfin/jellyfin-ux/55616553b692b1a6c7d8e786eeb7d8216e9b50df/branding/SVG/icon-transparent.svg', - primaryColor: '--jellyfin-blue', - }, - 'youtube-music': { - displayName: 'YouTube Music', - type: ['streaming'], - icon: 'https://upload.wikimedia.org/wikipedia/commons/6/6a/Youtube_Music_icon.svg', - primaryColor: '--youtube-red', - }, -} - export class Jellyfin { static audioPresets = (userId: string) => { return { @@ -140,6 +125,16 @@ export class Jellyfin { } export class YouTubeMusic { + static baseHeaders = { + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0', + accept: '*/*', + 'accept-encoding': 'gzip, deflate', + 'content-type': 'application/json', + 'content-encoding': 'gzip', + origin: 'https://music.youtube.com', + Cookie: 'SOCS=CAI;', + } + static fetchServiceInfo = async (userId: string, accessToken: string): Promise['service']> => { const youtube = google.youtube('v3') const userChannelResponse = await youtube.channels.list({ mine: true, part: ['snippet'], access_token: accessToken }) @@ -151,4 +146,58 @@ export class YouTubeMusic { profilePicture: userChannel.snippet?.thumbnails?.default?.url as string | undefined, } } + + static getVisitorId = async (accessToken: string): Promise => { + const headers = Object.assign(this.baseHeaders, { authorization: `Bearer ${accessToken}`, 'X-Goog-Request-Time': `${Date.now()}` }) + const visitorIdResponse = await fetch('https://music.youtube.com', { headers }) + const visitorIdText = await visitorIdResponse.text() + const regex = /ytcfg\.set\s*\(\s*({.+?})\s*\)\s*;/g + const matches = [] + let match + + while ((match = regex.exec(visitorIdText)) !== null) { + const capturedGroup = match[1] + matches.push(capturedGroup) + } + + let visitorId = '' + if (matches.length > 0) { + const ytcfg = JSON.parse(matches[0]) + visitorId = ytcfg.VISITOR_DATA + } + + return visitorId + } + + static getHome = async (accessToken: string) => { + const headers = Object.assign(this.baseHeaders, { authorization: `Bearer ${accessToken}`, 'X-Goog-Request-Time': `${Date.now()}` }) + + function formatDate(): string { + const currentDate = new Date() + const year = currentDate.getUTCFullYear() + const month = (currentDate.getUTCMonth() + 1).toString().padStart(2, '0') // Months are zero-based, so add 1 + const day = currentDate.getUTCDate().toString().padStart(2, '0') + + return year + month + day + } + + const response = await fetch(`https://music.youtube.com/youtubei/v1/browse?alt=json`, { + headers, + method: 'POST', + body: JSON.stringify({ + browseId: 'FEmusic_home', + context: { + client: { + clientName: 'WEB_REMIX', + clientVersion: '1.' + formatDate() + '.01.00', + hl: 'en', + }, + }, + }), + }) + + const data = await response.json() + console.log(response.status) + console.log(data.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicCarouselShelfRenderer.contents[0].musicTwoRowItemRenderer.title) + } } diff --git a/src/routes/(app)/+page.svelte b/src/routes/(app)/+page.svelte index 1279bb0..91bc96d 100644 --- a/src/routes/(app)/+page.svelte +++ b/src/routes/(app)/+page.svelte @@ -6,5 +6,5 @@
- +
diff --git a/src/routes/api/connections/+server.ts b/src/routes/api/connections/+server.ts index fe69720..3b716cf 100644 --- a/src/routes/api/connections/+server.ts +++ b/src/routes/api/connections/+server.ts @@ -17,6 +17,7 @@ export const GET: RequestHandler = async ({ url }) => { connection.service = await YouTubeMusic.fetchServiceInfo(connection.service.userId, connection.tokens.accessToken) break } + connections.push(connection) } return Response.json({ connections }) diff --git a/src/routes/api/users/[userId]/recommendations/+server.ts b/src/routes/api/users/[userId]/recommendations/+server.ts index 45efa71..7005f87 100644 --- a/src/routes/api/users/[userId]/recommendations/+server.ts +++ b/src/routes/api/users/[userId]/recommendations/+server.ts @@ -1,6 +1,7 @@ import type { RequestHandler } from '@sveltejs/kit' import { SECRET_INTERNAL_API_KEY } from '$env/static/private' -import { Jellyfin } from '$lib/services' +import { Jellyfin, YouTubeMusic } from '$lib/services' +import { google } from 'googleapis' // This is temporary functionally for the sake of developing the app. // In the future will implement more robust algorithm for offering recommendations @@ -33,6 +34,9 @@ export const GET: RequestHandler = async ({ params, fetch }) => { for (const song of mostPlayedData.Items) recommendations.push(Jellyfin.songFactory(song, connection)) break + case 'youtube-music': + YouTubeMusic.getHome(tokens.accessToken) + break } } diff --git a/src/routes/settings/connections/+page.server.ts b/src/routes/settings/connections/+page.server.ts index f1ff579..bc71736 100644 --- a/src/routes/settings/connections/+page.server.ts +++ b/src/routes/settings/connections/+page.server.ts @@ -43,15 +43,22 @@ export const actions: Actions = { return fail(400, { message: 'Could not reach Jellyfin server' }) } - const newConnection = Connections.addConnection('jellyfin', { + const newConnectionId = Connections.addConnection('jellyfin', { userId: locals.user.id, service: { userId: authData.User.Id, urlOrigin: serverUrl.toString() }, tokens: { accessToken: authData.AccessToken }, }) - return { newConnection } + const response = await fetch(`/api/connections?ids=${newConnectionId}`, { + method: 'GET', + headers: { apikey: SECRET_INTERNAL_API_KEY }, + }) + + const responseData = await response.json() + + return { newConnection: responseData.connections[0] } }, - youtubeMusicLogin: async ({ request, locals }) => { + youtubeMusicLogin: async ({ request, fetch, locals }) => { const formData = await request.formData() const { code } = Object.fromEntries(formData) const client = new google.auth.OAuth2({ clientId: PUBLIC_YOUTUBE_API_CLIENT_ID, clientSecret: YOUTUBE_API_CLIENT_SECRET, redirectUri: 'http://localhost:5173' }) // DO NOT SHIP THIS. THE CLIENT SECRET SHOULD NOT BE MADE AVAILABLE TO USERS. MAKE A REQUEST TO THE LAZULI WEBSITE INSTEAD. @@ -61,13 +68,20 @@ export const actions: Actions = { const userChannelResponse = await youtube.channels.list({ mine: true, part: ['id', 'snippet'], access_token: tokens.access_token! }) const userChannel = userChannelResponse.data.items![0] - const newConnection = Connections.addConnection('youtube-music', { + const newConnectionId = Connections.addConnection('youtube-music', { userId: locals.user.id, service: { userId: userChannel.id! }, tokens: { accessToken: tokens.access_token!, refreshToken: tokens.refresh_token!, expiry: tokens.expiry_date! }, }) - return { newConnection } + const response = await fetch(`/api/connections?ids=${newConnectionId}`, { + method: 'GET', + headers: { apikey: SECRET_INTERNAL_API_KEY }, + }) + + const responseData = await response.json() + + return { newConnection: responseData.connections[0] } }, deleteConnection: async ({ request }) => { const formData = await request.formData() diff --git a/src/routes/settings/connections/+page.svelte b/src/routes/settings/connections/+page.svelte index ffedcad..b5174c7 100644 --- a/src/routes/settings/connections/+page.svelte +++ b/src/routes/settings/connections/+page.svelte @@ -1,5 +1,5 @@
- {reactiveServiceData.displayName} icon - {#if 'profilePicture' in connection.service && typeof connection.service.profilePicture === 'string'} + {serviceData.displayName} icon + {#if 'profilePicture' in connection.service && connection.service.profilePicture} {/if}
-
Username
+
{serviceData.displayName}
- {reactiveServiceData.displayName} - {#if 'serverName' in connection.service} - - {connection.service.serverName} - {/if} + {subHeaderItems.join(' - ')}
diff --git a/vite.config.ts b/vite.config.ts index bbf8c7d..53c1903 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,6 @@ -import { sveltekit } from '@sveltejs/kit/vite'; -import { defineConfig } from 'vite'; +import { sveltekit } from '@sveltejs/kit/vite' +import { defineConfig } from 'vite' export default defineConfig({ - plugins: [sveltekit()] -}); + plugins: [sveltekit()], +}) From 1b4c91ba35c70b82f95f44a0db63ba7be4a44ff9 Mon Sep 17 00:00:00 2001 From: Eclypsed Date: Sun, 25 Feb 2024 00:33:34 -0500 Subject: [PATCH 2/2] How does anyone work with YouTube's internal API? Translated ytmusicapi's gethome() to JS, need to start refactoring the functions to produce lazuli media items. --- src/lib/server/users.db | Bin 24576 -> 24576 bytes src/lib/service-managers/youtube-music.ts | 477 ++++++++++++++++++ src/lib/services.ts | 80 --- src/routes/api/connections/+server.ts | 3 +- .../api/users/[userId]/connections/+server.ts | 3 +- .../users/[userId]/recommendations/+server.ts | 4 +- 6 files changed, 483 insertions(+), 84 deletions(-) create mode 100644 src/lib/service-managers/youtube-music.ts diff --git a/src/lib/server/users.db b/src/lib/server/users.db index 10018d8073b2a9e766c563c37417397ed92ae109..99406567db9076a8d5b60e6ff0b8eb4632759c45 100644 GIT binary patch delta 276 zcmV+v0qg#NzyW~30gxL37Lgo70T!`fr9TA?{s0aBvk?&V4GDvP0S8r8Cs&4(+g~M< zE>3fQLU3ryaY(r#6RZMJcZErJKT1#bXOjcxMIA?cKVl`QM zS$Z}(H9=S{MO0&YWKTgkd0#_nI7v7;NK;NlM{00ybxLzSyMq$O;biuNJm&QO=)6hb2mvzZE8#~P;_Q)Nlau?SaxSo aFflhXlWtFl2{<$`H90jlGcdFNPXQ1^t6M(+ delta 276 zcmZoTz}Rqrae_3X;6xc`M!}5Ou4QBo4OI_sW1V~_US+b5 zUva%lm2OUjVPaW+xwE%Xa->OKcz&K!dUk4js7s|^N>;L8NX*nrUFBQ?XZ(L8xoK zahP9ffpchpSGjY(d0s(yUXD?vWol)5l$)QgYgt-aMzVXLWm>qOm${i&MMOr0V@YL( zd9tUau79F)dZf2wib=3zkZ-VCpoe>ek#DA] + } + strapline: [runs] + accessibilityData: accessibilityData + headerStyle: string + moreContentButton?: { + buttonRenderer: { + style: string + text: { + runs: [runs] + } + navigationEndpoint: navigationEndpoint<'browse'> + trackingParams: string + accessibilityData: accessibilityData + } + } + thumbnail?: musicThumbnailRenderer + trackingParams: string + } + } + contents: { + musicTwoRowItemRenderer?: musicTwoRowItemRenderer + musicResponsiveListItemRenderer?: unknown + }[] + trackingParams: string + itemSize: string + } + + type musicDescriptionShelfRenderer = { + header: { + runs: [runs] + } + description: { + runs: [runs] + } + } + + type musicTwoRowItemRenderer = { + thumbnailRenderer: { + musicThumbnailRenderer: musicThumbnailRenderer + } + aspectRatio: string + title: { + runs: [runs<'browse'>] + } + subtitle: { + runs: runs<'browse'>[] + } + navigationEndpoint: navigationEndpoint + trackingParams: string + menu: unknown + thumbnailOverlay: unknown + } + + type musicThumbnailRenderer = { + thumbnail: { + thumbnails: { + url: string + width: number + height: number + }[] + } + thumbnailCrop: string + thumbnailScale: string + trackingParams: string + accessibilityData?: accessibilityData + onTap?: navigationEndpoint<'browse'> + targetId?: string + } + + type runs = endpoint extends endpointType + ? { + text: string + navigationEndpoint?: navigationEndpoint + } + : { text: string } + + type endpointType = 'browse' | 'watch' | 'watchPlaylist' + type navigationEndpoint = T extends 'browse' + ? { + clickTrackingParams: string + browseEndpoint: { + browseId: string + params?: string + browseEndpointContextSupportedConfigs: { + browseEndpointContextMusicConfig: { + pageType: 'MUSIC_PAGE_TYPE_ALBUM' | 'MUSIC_PAGE_TYPE_ARTIST' | 'MUSIC_PAGE_TYPE_PLAYLIST' + } + } + } + } + : T extends 'watch' + ? { + clickTrackingParams: string + watchEndpoint: { + videoId: string + playlistId: string + params?: string + loggingContext: { + vssLoggingContext: object + } + watchEndpointMusicSupportedConfigs: { + watchEndpointMusicConfig: object + } + } + } + : T extends 'watchPlaylist' + ? { + clickTrackingParams: string + watchPlaylistEndpoint: { + playlistId: string + params?: string + } + } + : never + + type accessibilityData = { + accessibilityData: { + label: string + } + } +} + +export class YouTubeMusic { + static baseHeaders = { + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0', + accept: '*/*', + 'accept-encoding': 'gzip, deflate', + 'content-type': 'application/json', + 'content-encoding': 'gzip', + origin: 'https://music.youtube.com', + Cookie: 'SOCS=CAI;', + } + + static fetchServiceInfo = async (userId: string, accessToken: string): Promise['service']> => { + const youtube = google.youtube('v3') + const userChannelResponse = await youtube.channels.list({ mine: true, part: ['snippet'], access_token: accessToken }) + const userChannel = userChannelResponse.data.items![0] + + return { + userId, + username: userChannel.snippet?.title as string, + profilePicture: userChannel.snippet?.thumbnails?.default?.url as string | undefined, + } + } + + static getVisitorId = async (accessToken: string): Promise => { + const headers = Object.assign(this.baseHeaders, { authorization: `Bearer ${accessToken}`, 'X-Goog-Request-Time': `${Date.now()}` }) + const visitorIdResponse = await fetch('https://music.youtube.com', { headers }) + const visitorIdText = await visitorIdResponse.text() + const regex = /ytcfg\.set\s*\(\s*({.+?})\s*\)\s*;/g + const matches = [] + let match + + while ((match = regex.exec(visitorIdText)) !== null) { + const capturedGroup = match[1] + matches.push(capturedGroup) + } + + let visitorId = '' + if (matches.length > 0) { + const ytcfg = JSON.parse(matches[0]) + visitorId = ytcfg.VISITOR_DATA + } + + return visitorId + } + + static getHome = async (accessToken: string) => { + const headers = Object.assign(this.baseHeaders, { authorization: `Bearer ${accessToken}`, 'X-Goog-Request-Time': `${Date.now()}` }) + + function formatDate(): string { + const currentDate = new Date() + const year = currentDate.getUTCFullYear() + const month = (currentDate.getUTCMonth() + 1).toString().padStart(2, '0') // Months are zero-based, so add 1 + const day = currentDate.getUTCDate().toString().padStart(2, '0') + + return year + month + day + } + + const response = await fetch(`https://music.youtube.com/youtubei/v1/browse?alt=json`, { + headers, + method: 'POST', + body: JSON.stringify({ + browseId: 'FEmusic_home', + context: { + client: { + clientName: 'WEB_REMIX', + clientVersion: '1.' + formatDate() + '.01.00', + hl: 'en', + }, + }, + }), + }) + + console.log(response.status) + const data: InnerTube.BrowseResponse = await response.json() + const results = data.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents + const home: any[] = [] + home.push.apply(home, Parsers.parseMixedContent(results)) + + // const sectionList = data.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer + // if ('continuations' in sectionList) { + + // } + + // return home + } +} + +class Parsers { + static parseMixedContent = (rows: { musicCarouselShelfRenderer: InnerTube.musicCarouselShelfRenderer }[] | { musicDescriptionShelfRenderer: InnerTube.musicDescriptionShelfRenderer }[]) => { + const items = [] + for (const row of rows) { + let title: string, contents: string + if ('musicDescriptionShelfRenderer' in row) { + const results = row.musicDescriptionShelfRenderer + title = results.header.runs[0].text + contents = results.description.runs[0].text + } else { + const results = row.musicCarouselShelfRenderer + if (!('contents' in results)) continue + + title = results.header.musicCarouselShelfBasicHeaderRenderer.title.runs[0].text + contents = [] + for (const result of results.contents) { + let content + if (result.musicTwoRowItemRenderer) { + const data = result.musicTwoRowItemRenderer + const pageType = data.title.runs[0].navigationEndpoint?.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType + if (!pageType) { + if ('watchPlaylistEndpoint' in data.navigationEndpoint) { + content = this.parseWatchPlaylist(data) + } else { + content = this.parseSong(data) + } + } else if (pageType === 'MUSIC_PAGE_TYPE_ALBUM') { + content = this.parseAlbum(data) + } else if (pageType === 'MUSIC_PAGE_TYPE_ARTIST') { + content = this.parseRelatedArtist(data) + } else if (pageType === 'MUSIC_PAGE_TYPE_PLAYLIST') { + content = this.parsePlaylist(data) + } + } else { + const data = result.musicResponsiveListItemRenderer + if (!data) continue + content = this.parseSongFlat(data) + } + + contents.push(content) + } + } + + items.push({ title, contents }) + } + + return items + } + + static parseSong = (data: InnerTube.musicTwoRowItemRenderer) => { + const song = { + title: data.title.runs[0].text, + videoId: data.navigationEndpoint.watchEndpoint.videoId, + playlistId: data?.navigationEndpoint?.watchEndpoint?.playlistId, + thumbnails: data.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails, + } + const fullSong = Object.assign(song, this.parseSongRuns(data.subtitle.runs)) + return fullSong + } + + static parseSongRuns = (runs: any) => { + const parsed: Record = { artists: [] } + for (let i = 0; i < runs.length; ++i) { + if (i % 2) continue + + const run = runs[i], + text = run.text + if ('navigationEndpoint' in run) { + const item = { name: text, id: run?.navigationEndpoint?.browseEndpoint?.browseId } + + if (item.id && (item.id.startsWith('MPRE') || item.id.includes('release_detail'))) { + parsed.album = item + } else { + parsed.artists.push(item) + } + } else { + if (/^\d([^ ])* [^ ]*$/.test(text) && i > 0) { + parsed.views = text.split(' ')[0] + } else if (/^(\d+:)*\d+:\d+$/.test(text)) { + parsed.duration = text + parsed.durationSeconds = this.parseDuration(text) + } else if (/^\d{4}$/.test(text)) { + parsed.year = text + } else { + parsed.artists.push({ name: text, id: null }) + } + } + } + + return parsed + } + + static parseSongFlat = (data: any) => { + const columns = [] + for (let i = 0; i < data.flexColumns.length; ++i) columns.push(this.getFlexColumnItem(data, i)) + const song: Record = { + title: columns[0].text.runs[0].text, + videoId: columns[0].text.runs[0]?.navigationEndpoint?.watchEndpoint?.videoId, + artists: this.parseSongArtists(data, 1), + thumbnails: data.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails, + isExplicit: Boolean(data?.badges?.at(0)?.musicInlineBadgeRenderer?.accessibilityData?.accessibilityData?.label), + } + if (columns.length > 2 && columns[2] && 'navigationEndpoint' in columns[2].text.runs[0]) { + song.album = { + name: columns[2].text.runs[0].text, + id: columns[2].text.runs[0].navigationEndpoint.browseEndpoint.browseId, + } + } else { + song.views = columns[1].text.runs.at(-1).text.split(' ')[0] + } + + return song + } + + static parseAlbum = (data: InnerTube.musicTwoRowItemRenderer) => { + return { + title: data.title.runs[0].text, + type: data.subtitle.runs[0].text, + year: data.subtitle.runs[2].text, + artists: Array.from(data.subtitle.runs, (x: any) => { + if ('navigationEndpoint' in x) return this.parseIdName(x) + }), + browseId: data.title.runs[0].navigationEndpoint.browseEndpoint.browseId, + audioPlaylistId: data?.thumbnailOverlay?.musicItemThumbnailOverlayRenderer?.content?.musicPlayButtonRenderer?.playNavigationEndpoint?.watchPlaylistEndpoint?.playlistId, + thumbnails: data.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails, + isExplicit: Boolean(data?.subtitleBadges?.at(0)?.musicInlineBadgeRenderer.accessibilityData.accessibilityData.label), + } + } + + static parseSongArtists = (data: any, index: number) => { + const flexItem = this.getFlexColumnItem(data, index) + if (!flexItem) { + console.log('fired') + return null + } else { + const runs = flexItem.text.runs + return this.parseSongArtistRuns(runs) + } + } + + static parseRelatedArtist = (data: InnerTube.musicTwoRowItemRenderer) => { + let subscribers = data?.subtitle?.runs[0]?.text + if (subscribers) subscribers = subscribers.split(' ')[0] + return { + title: data.title.runs[0].text, + browseId: data.title.runs[0].navigationEndpoint.browseEndpoint.browseId, + subscribers, + thumbnails: data.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails, + } + } + + static parseSongArtistRuns = (runs: any) => { + const artists = [] + for (let j = 0; j <= Math.floor(runs.length / 2); j++) { + artists.push({ name: runs[j * 2].text, id: runs[j * 2]?.navigationEndpoint?.browseEndpoint?.browseId }) + } + return artists + } + + static parseDuration = (duration: any) => { + if (!duration) return duration + const mappedIncrements = [1, 60, 3600], + reversedTimes = duration.split(':').reverse() + const seconds = mappedIncrements.reduce((accumulator, multiplier, index) => { + return accumulator + multiplier * parseInt(reversedTimes[index]) + }, 0) + return seconds + } + + static parseIdName = (data: any) => { + return { + id: data?.navigationEndpoint?.browseEndpoint?.browseId, + name: data?.text, + } + } + + static parsePlaylist = (data: InnerTube.musicTwoRowItemRenderer) => { + const playlist: Record = { + title: data.title.runs[0].text, + playlistId: data.title.runs[0].navigationEndpoint.browseEndpoint.browseId.slice(2), + thumbnails: data.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails, + } + const subtitle = data.subtitle + if ('runs' in subtitle) { + playlist.description = Array.from(subtitle.runs, (run: any) => { + return run.text + }).join('') + if (subtitle.runs.length === 3 && data.subtitle.runs[2].text.match(/\d+ /)) { + playlist.count = data.subtitle.runs[2].text.split(' ')[0] + playlist.author = this.parseSongArtistRuns(subtitle.runs.slice(0, 1)) + } + } + return playlist + } + + static parseWatchPlaylist = (data: InnerTube.musicTwoRowItemRenderer) => { + return { + title: data.title.runs[0].text, + playlistId: data.navigationEndpoint.watchPlaylistEndpoint.playlistId, + thumbnails: data.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails, + } + } + + static getFlexColumnItem = (item: any, index: number) => { + if (item.flexColumns.length <= index || !('text' in item.flexColumns[index].musicResponsiveListItemFlexColumnRenderer) || !('runs' in item.flexColumns[index].musicResponsiveListItemFlexColumnRenderer.text)) { + return null + } + return item.flexColumns[index].musicResponsiveListItemFlexColumnRenderer + } +} diff --git a/src/lib/services.ts b/src/lib/services.ts index 2845321..a112d25 100644 --- a/src/lib/services.ts +++ b/src/lib/services.ts @@ -1,5 +1,3 @@ -import { google } from 'googleapis' - export class Jellyfin { static audioPresets = (userId: string) => { return { @@ -123,81 +121,3 @@ export class Jellyfin { } } } - -export class YouTubeMusic { - static baseHeaders = { - 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0', - accept: '*/*', - 'accept-encoding': 'gzip, deflate', - 'content-type': 'application/json', - 'content-encoding': 'gzip', - origin: 'https://music.youtube.com', - Cookie: 'SOCS=CAI;', - } - - static fetchServiceInfo = async (userId: string, accessToken: string): Promise['service']> => { - const youtube = google.youtube('v3') - const userChannelResponse = await youtube.channels.list({ mine: true, part: ['snippet'], access_token: accessToken }) - const userChannel = userChannelResponse.data.items![0] - - return { - userId, - username: userChannel.snippet?.title as string, - profilePicture: userChannel.snippet?.thumbnails?.default?.url as string | undefined, - } - } - - static getVisitorId = async (accessToken: string): Promise => { - const headers = Object.assign(this.baseHeaders, { authorization: `Bearer ${accessToken}`, 'X-Goog-Request-Time': `${Date.now()}` }) - const visitorIdResponse = await fetch('https://music.youtube.com', { headers }) - const visitorIdText = await visitorIdResponse.text() - const regex = /ytcfg\.set\s*\(\s*({.+?})\s*\)\s*;/g - const matches = [] - let match - - while ((match = regex.exec(visitorIdText)) !== null) { - const capturedGroup = match[1] - matches.push(capturedGroup) - } - - let visitorId = '' - if (matches.length > 0) { - const ytcfg = JSON.parse(matches[0]) - visitorId = ytcfg.VISITOR_DATA - } - - return visitorId - } - - static getHome = async (accessToken: string) => { - const headers = Object.assign(this.baseHeaders, { authorization: `Bearer ${accessToken}`, 'X-Goog-Request-Time': `${Date.now()}` }) - - function formatDate(): string { - const currentDate = new Date() - const year = currentDate.getUTCFullYear() - const month = (currentDate.getUTCMonth() + 1).toString().padStart(2, '0') // Months are zero-based, so add 1 - const day = currentDate.getUTCDate().toString().padStart(2, '0') - - return year + month + day - } - - const response = await fetch(`https://music.youtube.com/youtubei/v1/browse?alt=json`, { - headers, - method: 'POST', - body: JSON.stringify({ - browseId: 'FEmusic_home', - context: { - client: { - clientName: 'WEB_REMIX', - clientVersion: '1.' + formatDate() + '.01.00', - hl: 'en', - }, - }, - }), - }) - - const data = await response.json() - console.log(response.status) - console.log(data.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicCarouselShelfRenderer.contents[0].musicTwoRowItemRenderer.title) - } -} diff --git a/src/routes/api/connections/+server.ts b/src/routes/api/connections/+server.ts index 3b716cf..d16f113 100644 --- a/src/routes/api/connections/+server.ts +++ b/src/routes/api/connections/+server.ts @@ -1,5 +1,6 @@ import type { RequestHandler } from '@sveltejs/kit' -import { Jellyfin, YouTubeMusic } from '$lib/services' +import { Jellyfin } from '$lib/services' +import { YouTubeMusic } from '$lib/service-managers/youtube-music' import { Connections } from '$lib/server/users' export const GET: RequestHandler = async ({ url }) => { diff --git a/src/routes/api/users/[userId]/connections/+server.ts b/src/routes/api/users/[userId]/connections/+server.ts index 2f164df..a742f7c 100644 --- a/src/routes/api/users/[userId]/connections/+server.ts +++ b/src/routes/api/users/[userId]/connections/+server.ts @@ -1,5 +1,6 @@ import { Connections } from '$lib/server/users' -import { Jellyfin, YouTubeMusic } from '$lib/services' +import { Jellyfin } from '$lib/services' +import { YouTubeMusic } from '$lib/service-managers/youtube-music' import type { RequestHandler } from '@sveltejs/kit' export const GET: RequestHandler = async ({ params }) => { diff --git a/src/routes/api/users/[userId]/recommendations/+server.ts b/src/routes/api/users/[userId]/recommendations/+server.ts index 7005f87..f2c8793 100644 --- a/src/routes/api/users/[userId]/recommendations/+server.ts +++ b/src/routes/api/users/[userId]/recommendations/+server.ts @@ -1,7 +1,7 @@ import type { RequestHandler } from '@sveltejs/kit' import { SECRET_INTERNAL_API_KEY } from '$env/static/private' -import { Jellyfin, YouTubeMusic } from '$lib/services' -import { google } from 'googleapis' +import { Jellyfin } from '$lib/services' +import { YouTubeMusic } from '$lib/service-managers/youtube-music' // This is temporary functionally for the sake of developing the app. // In the future will implement more robust algorithm for offering recommendations