I can't believe I figured out how to emulate ytmusic api calls

This commit is contained in:
Eclypsed
2024-02-24 02:06:02 -05:00
parent c7b9b214b7
commit fe37c8aa6e
12 changed files with 166 additions and 72 deletions

62
package-lock.json generated
View File

@@ -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"

Binary file not shown.

View File

@@ -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<T extends serviceType>(type: T, connectionData: Omit<DBConnectionData<T>, 'id' | 'type'>): DBConnectionData<T> {
static addConnection<T extends serviceType>(type: T, connectionData: Omit<DBConnectionData<T>, '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<T>
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')
}

14
src/lib/services.json Normal file
View File

@@ -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"
}
}

View File

@@ -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<Connection<'youtube-music'>['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<string> => {
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)
}
}

View File

@@ -6,5 +6,5 @@
</script>
<div id="main">
<ScrollableCardMenu header={'Listen Again'} cardDataList={data.recommendations} />
<!-- <ScrollableCardMenu header={'Listen Again'} cardDataList={data.recommendations} /> -->
</div>

View File

@@ -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 })

View File

@@ -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
}
}

View File

@@ -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()

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { serviceData } from '$lib/services'
import Services from '$lib/services.json'
import JellyfinAuthBox from './jellyfinAuthBox.svelte'
import { newestAlert } from '$lib/stores.js'
import type { PageServerData } from './$types.js'
@@ -38,7 +38,7 @@
connections = [...connections, newConnection]
newConnectionModal = null
return ($newestAlert = ['success', `Added ${serviceData[newConnection.type].displayName}`])
return ($newestAlert = ['success', `Added ${Services[newConnection.type].displayName}`])
}
}
}
@@ -75,9 +75,6 @@
}
const profileActions: SubmitFunction = ({ action, cancel }) => {
console.log(action)
cancel()
return ({ result }) => {
if (result.type === 'failure') {
return ($newestAlert = ['warning', result.data?.message])
@@ -89,7 +86,7 @@
connections.splice(indexToDelete, 1)
connections = connections
return ($newestAlert = ['success', `Deleted ${serviceData[serviceType].displayName}`])
return ($newestAlert = ['success', `Deleted ${Services[serviceType].displayName}`])
}
}
}
@@ -102,11 +99,11 @@
<h1 class="py-2 text-xl">Add Connection</h1>
<div class="flex flex-wrap gap-2 pb-4">
<button class="add-connection-button h-14 rounded-md" on:click={() => (newConnectionModal = JellyfinAuthBox)}>
<img src={serviceData.jellyfin.icon} alt="{serviceData.jellyfin.displayName} icon" class="aspect-square h-full p-2" />
<img src={Services.jellyfin.icon} alt="{Services.jellyfin.displayName} icon" class="aspect-square h-full p-2" />
</button>
<form method="post" action="?/youtubeMusicLogin" use:enhance={authenticateYouTube}>
<button class="add-connection-button h-14 rounded-md">
<img src={serviceData['youtube-music'].icon} alt="{serviceData['youtube-music'].displayName} icon" class="aspect-square h-full p-2" />
<img src={Services['youtube-music'].icon} alt="{Services['youtube-music'].displayName} icon" class="aspect-square h-full p-2" />
</button>
</form>
</div>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { serviceData } from '$lib/services'
import Services from '$lib/services.json'
import IconButton from '$lib/components/util/iconButton.svelte'
import Toggle from '$lib/components/util/toggle.svelte'
import type { SubmitFunction } from '@sveltejs/kit'
@@ -9,26 +9,27 @@
export let connection: Connection<serviceType>
export let submitFunction: SubmitFunction
$: reactiveServiceData = serviceData[connection.type]
$: serviceData = Services[connection.type]
let showModal = false
const subHeaderItems: string[] = []
if ('username' in connection.service && connection.service.username) subHeaderItems.push(connection.service.username)
if ('serverName' in connection.service && connection.service.serverName) subHeaderItems.push(connection.service.serverName)
</script>
<section class="rounded-lg" style="background-color: rgba(82, 82, 82, 0.25);" transition:fly={{ x: 50 }}>
<header class="flex h-20 items-center gap-4 p-4">
<div class="relative aspect-square h-full p-1">
<img src={reactiveServiceData.icon} alt="{reactiveServiceData.displayName} icon" />
{#if 'profilePicture' in connection.service && typeof connection.service.profilePicture === 'string'}
<img src={serviceData.icon} alt="{serviceData.displayName} icon" />
{#if 'profilePicture' in connection.service && connection.service.profilePicture}
<img src={connection.service.profilePicture} alt="" class="absolute bottom-0 right-0 aspect-square h-5 rounded-full" />
{/if}
</div>
<div>
<div>Username</div>
<div>{serviceData.displayName}</div>
<div class="text-sm text-neutral-500">
{reactiveServiceData.displayName}
{#if 'serverName' in connection.service}
- {connection.service.serverName}
{/if}
{subHeaderItems.join(' - ')}
</div>
</div>
<div class="relative ml-auto flex h-8 flex-row-reverse gap-2">

View File

@@ -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()],
})