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.ts b/src/lib/server/users.ts index 52306c0..1779bcd 100644 --- a/src/lib/server/users.ts +++ b/src/lib/server/users.ts @@ -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/service-managers/youtube-music.ts b/src/lib/service-managers/youtube-music.ts new file mode 100644 index 0000000..e306681 --- /dev/null +++ b/src/lib/service-managers/youtube-music.ts @@ -0,0 +1,508 @@ +import { google } from 'googleapis' + +declare namespace InnerTube { + interface BrowseResponse { + responseContext: { + visitorData: string + serviceTrackingParams: object[] + maxAgeSeconds: number + } + contents: { + singleColumnBrowseResultsRenderer: { + tabs: [ + { + tabRenderer: { + endpoint: object + title: 'Home' + selected: boolean + content: { + sectionListRenderer: { + contents: + | { + musicCarouselShelfRenderer: musicCarouselShelfRenderer + }[] + | { + musicDescriptionShelfRenderer: musicDescriptionShelfRenderer + }[] + continuations: [object] + trackingParams: string + header: { + chipCloudRenderer: object + } + } + } + icon: object + tabIdentifier: 'FEmusic_home' + trackingParams: string + } + }, + ] + } + } + trackingParams: string + maxAgeStoreSeconds: number + background: { + musicThumbnailRenderer: { + thumbnail: object + thumbnailCrop: string + thumbnailScale: string + trackingParams: string + } + } + } + + type musicCarouselShelfRenderer = { + header: { + musicCarouselShelfBasicHeaderRenderer: { + title: { + runs: [runs<'browse'>] + } + 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: browseEndpoint + } + : T extends 'watch' + ? { + clickTrackingParams: string + watchEndpoint: watchEndpoint + } + : T extends 'watchPlaylist' + ? { + clickTrackingParams: string + watchPlaylistEndpoint: watchPlaylistEndpoint + } + : never + + type browseEndpoint = { + browseId: string + params?: string + browseEndpointContextSupportedConfigs: { + browseEndpointContextMusicConfig: { + pageType: 'MUSIC_PAGE_TYPE_ALBUM' | 'MUSIC_PAGE_TYPE_ARTIST' | 'MUSIC_PAGE_TYPE_PLAYLIST' + } + } + } + + type watchEndpoint = { + videoId: string + playlistId: string + params?: string + loggingContext: { + vssLoggingContext: object + } + watchEndpointMusicSupportedConfigs: { + watchEndpointMusicConfig: object + } + } + + type watchPlaylistEndpoint = { + playlistId: string + params?: string + } + + 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): Promise => { + 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) { + if ('musicDescriptionShelfRenderer' in row) { + const results = row.musicDescriptionShelfRenderer + const title = results.header.runs[0].text + const contents = results.description.runs[0].text + items.push({ title, contents }) + } else { + const results = row.musicCarouselShelfRenderer + if (!('contents' in results)) continue + + const title = results.header.musicCarouselShelfBasicHeaderRenderer.title.runs[0].text + const 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: { [K in keyof InnerTube.musicTwoRowItemRenderer]: K extends 'navigationEndpoint' ? InnerTube.navigationEndpoint<'watch'> : InnerTube.musicTwoRowItemRenderer[K] }): Song => { + const runs = data.subtitle.runs + const parsed: Partial = { artists: [] } + for (let i = 0; i < runs.length; ++i) { + if (i % 2) continue + const run = runs[i], + text = run.text + + if (run.navigationEndpoint) { + const item = { name: text, id: run.navigationEndpoint.browseEndpoint.browseId } + + if (item.id.startsWith('MPRE') || item.id.includes('release_detail')) { + parsed.albumId = run.navigationEndpoint.browseEndpoint.browseId + } else { + parsed.artists?.push({ id: run.navigationEndpoint.browseEndpoint.browseId, name: text }) + } + } else { + if (/^(\d+:)*\d+:\d+$/.test(text)) { + parsed.duration = this.parseDuration(text) + } else if (/^\d{4}$/.test(text)) { + parsed.releaseDate = text + } + } + } + + const song: Song = { + serviceType: 'youtube-music', + type: 'song', + id: data.navigationEndpoint.watchEndpoint.videoId, + name: data.title.runs[0].text, + thumbnail: data.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails.reduce((largest, current) => { + return current.width * current.height > largest.width * largest.height ? current : largest + }).url, + duration: parsed.d, + } + return song + } + + 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: string): number => { + 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.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..a112d25 100644 --- a/src/lib/services.ts +++ b/src/lib/services.ts @@ -1,20 +1,3 @@ -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 { @@ -138,17 +121,3 @@ export class Jellyfin { } } } - -export class YouTubeMusic { - 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, - } - } -} 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..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 }) => { @@ -17,6 +18,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]/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 45efa71..f2c8793 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 { 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 @@ -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()], +})