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 e69de29..10018d8 100644 Binary files a/src/lib/server/users.db and b/src/lib/server/users.db differ 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()], +})