Might have to shift to googleapis rather than trying to parse the emulated responses; too much info missing
This commit is contained in:
62
package-lock.json
generated
62
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
508
src/lib/service-managers/youtube-music.ts
Normal file
508
src/lib/service-managers/youtube-music.ts
Normal file
@@ -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<endpointType>
|
||||
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 | undefined = undefined> = endpoint extends endpointType
|
||||
? {
|
||||
text: string
|
||||
navigationEndpoint?: navigationEndpoint<endpoint>
|
||||
}
|
||||
: { text: string }
|
||||
|
||||
type endpointType = 'browse' | 'watch' | 'watchPlaylist'
|
||||
type navigationEndpoint<T extends endpointType> = 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<Connection<'youtube-music'>['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<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): Promise<MediaItem[]> => {
|
||||
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<Song> = { 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<string, any> = { 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<string, any> = {
|
||||
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<string, any> = {
|
||||
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
|
||||
}
|
||||
}
|
||||
14
src/lib/services.json
Normal file
14
src/lib/services.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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<Connection<'youtube-music'>['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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
</script>
|
||||
|
||||
<div id="main">
|
||||
<ScrollableCardMenu header={'Listen Again'} cardDataList={data.recommendations} />
|
||||
<!-- <ScrollableCardMenu header={'Listen Again'} cardDataList={data.recommendations} /> -->
|
||||
</div>
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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()],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user