Working on ytmusic parsers

This commit is contained in:
Eclypsed
2024-02-28 03:03:40 -05:00
parent ade2ee9b86
commit 79bbead5e4
7 changed files with 125 additions and 45 deletions

31
src/app.d.ts vendored
View File

@@ -32,8 +32,6 @@ declare global {
// They are NOT meant to be stores for large amounts of data, i.e. Don't include the data for every single song the Playlist type. // They are NOT meant to be stores for large amounts of data, i.e. Don't include the data for every single song the Playlist type.
// Big data should be fetched as needed in the app, these exist to ensure that the info necessary to fetch that data is there. // Big data should be fetched as needed in the app, these exist to ensure that the info necessary to fetch that data is there.
interface MediaItem { interface MediaItem {
connectionId: string
serviceType: serviceType
type: 'song' | 'album' | 'playlist' | 'artist' type: 'song' | 'album' | 'playlist' | 'artist'
id: string id: string
name: string name: string
@@ -41,36 +39,47 @@ declare global {
} }
interface Song extends MediaItem { interface Song extends MediaItem {
connectionId: string
serviceType: serviceType
type: 'song' type: 'song'
duration: number duration: number
artists: { artists?: {
id: string id: string
name: string name: string
}[] }[]
albumId?: string album?: {
audio: string id: string
video?: string name: string
releaseDate: string }
// audio: string <--- Because of youtube these will potentially expire. They are also not needed until a user requests that song, so instead fetch them as needed
// video?: string
releaseDate?: string
} }
interface Album extends MediaItem { interface Album extends MediaItem {
connectionId: string
serviceType: serviceType
type: 'album' type: 'album'
duration: number duration: number
albumArtists: { albumArtists?: {
id: string id: string
name: string name: string
}[] }[]
artists: { artists?: {
id: string id: string
name: string name: string
}[] }[]
releaseDate: string releaseDate?: string
} }
// IMPORTANT: This interface is for Lazuli created and stored playlists. Use service-specific interfaces when pulling playlists from services
interface Playlist extends MediaItem { interface Playlist extends MediaItem {
type: 'playlist' type: 'playlist'
duration: number
description?: string description?: string
items: {
connectionId: string
id: string
}[]
} }
interface Artist extends MediaItem { interface Artist extends MediaItem {

Binary file not shown.

View File

@@ -16,7 +16,15 @@ const initConnectionsTable = `CREATE TABLE IF NOT EXISTS Connections(
tokens TEXT, tokens TEXT,
FOREIGN KEY(userId) REFERENCES Users(id) FOREIGN KEY(userId) REFERENCES Users(id)
)` )`
db.exec(initUsersTable), db.exec(initConnectionsTable) const initPlaylistsTable = `CREATE TABLE IF NOT EXISTS Playlists(
id VARCHAR(36) PRIMARY KEY,
userId VARCHAR(36) NOT NULL,
name TEXT NOT NULL,
description TEXT,
items TEXT,
FOREIGN KEY(userId) REFERENCES Users(id)
)`
db.exec(initUsersTable), db.exec(initConnectionsTable), db.exec(initPlaylistsTable)
interface ConnectionsTableSchema { interface ConnectionsTableSchema {
id: string id: string
@@ -51,10 +59,12 @@ export class Users {
} }
} }
type DBServiceData<T extends serviceType> = T extends 'jellyfin' ? Pick<Jellyfin.Connection['service'], 'userId' | 'urlOrigin'> : T extends 'youtube-music' ? Pick<YouTubeMusic.Connection['service'], 'userId'> : never
type DBConnectionData<T extends serviceType> = T extends 'jellyfin' type DBConnectionData<T extends serviceType> = T extends 'jellyfin'
? Omit<Jellyfin.Connection, 'service'> & { service: Pick<Jellyfin.Connection['service'], 'userId' | 'urlOrigin'> } ? Omit<Jellyfin.Connection, 'service'> & { service: DBServiceData<'jellyfin'> }
: T extends 'youtube-music' : T extends 'youtube-music'
? Omit<YouTubeMusic.Connection, 'service'> & { service: Pick<YouTubeMusic.Connection['service'], 'userId'> } ? Omit<YouTubeMusic.Connection, 'service'> & { service: DBServiceData<'youtube-music'> }
: never : never
export class Connections { export class Connections {
@@ -77,9 +87,8 @@ export class Connections {
return connections return connections
} }
static addConnection<T extends serviceType>(type: T, connectionData: Omit<DBConnectionData<T>, 'id' | 'type'>): string { static addConnection<T extends serviceType>(type: T, userId: string, service: DBServiceData<T>, tokens: Connection<T>['tokens']): string {
const connectionId = generateUUID() const connectionId = generateUUID()
const { userId, service, tokens } = connectionData
db.prepare(`INSERT INTO Connections(id, userId, type, service, tokens) VALUES(?, ?, ?, ?, ?)`).run(connectionId, userId, type, JSON.stringify(service), JSON.stringify(tokens)) db.prepare(`INSERT INTO Connections(id, userId, type, service, tokens) VALUES(?, ?, ?, ?, ?)`).run(connectionId, userId, type, JSON.stringify(service), JSON.stringify(tokens))
return connectionId return connectionId
} }
@@ -106,3 +115,13 @@ export class Connections {
return connections return connections
} }
} }
class Playlists {
static newPlaylist = (userId: string, name: string): string => {
const playlistId = generateUUID()
db.prepare(`INSERT INTO Playlists(id, userId, name) VALUES(?, ?, ?)`).run(playlistId, userId, name)
return playlistId
}
static updateDescription = (id: string, description: string): void => {}
}

View File

@@ -1,4 +1,5 @@
import { google } from 'googleapis' import { google } from 'googleapis'
import ytdl from 'ytdl-core'
declare namespace InnerTube { declare namespace InnerTube {
interface BrowseResponse { interface BrowseResponse {
@@ -177,10 +178,25 @@ declare namespace InnerTube {
label: string label: string
} }
} }
interface YouTubeMusicClient {
userId: string
accessToken: string
}
} }
export class YouTubeMusic { export class YouTubeMusic {
static baseHeaders = { connectionId: string
userId: string
accessToken: string
constructor(connection: YouTubeMusic.Connection) {
this.connectionId = connection.id
this.userId = connection.service.userId
this.accessToken = connection.tokens.accessToken
}
private BASEHEADERS = {
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0', 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0',
accept: '*/*', accept: '*/*',
'accept-encoding': 'gzip, deflate', 'accept-encoding': 'gzip, deflate',
@@ -190,20 +206,20 @@ export class YouTubeMusic {
Cookie: 'SOCS=CAI;', Cookie: 'SOCS=CAI;',
} }
static fetchServiceInfo = async (userId: string, accessToken: string): Promise<Connection<'youtube-music'>['service']> => { public fetchServiceInfo = async (): Promise<YouTubeMusic.Connection['service']> => {
const youtube = google.youtube('v3') const youtube = google.youtube('v3')
const userChannelResponse = await youtube.channels.list({ mine: true, part: ['snippet'], access_token: accessToken }) const userChannelResponse = await youtube.channels.list({ mine: true, part: ['snippet'], access_token: this.accessToken })
const userChannel = userChannelResponse.data.items![0] const userChannel = userChannelResponse.data.items![0]
return { return {
userId, userId: this.userId,
username: userChannel.snippet?.title as string, username: userChannel.snippet?.title as string,
profilePicture: userChannel.snippet?.thumbnails?.default?.url as string | undefined, profilePicture: userChannel.snippet?.thumbnails?.default?.url as string | undefined,
} }
} }
static getVisitorId = async (accessToken: string): Promise<string> => { private getVisitorId = async (): Promise<string> => {
const headers = Object.assign(this.baseHeaders, { authorization: `Bearer ${accessToken}`, 'X-Goog-Request-Time': `${Date.now()}` }) const headers = Object.assign(this.BASEHEADERS, { authorization: `Bearer ${this.accessToken}`, 'X-Goog-Request-Time': `${Date.now()}` })
const visitorIdResponse = await fetch('https://music.youtube.com', { headers }) const visitorIdResponse = await fetch('https://music.youtube.com', { headers })
const visitorIdText = await visitorIdResponse.text() const visitorIdText = await visitorIdResponse.text()
const regex = /ytcfg\.set\s*\(\s*({.+?})\s*\)\s*;/g const regex = /ytcfg\.set\s*\(\s*({.+?})\s*\)\s*;/g
@@ -224,17 +240,17 @@ export class YouTubeMusic {
return visitorId return visitorId
} }
static getHome = async (accessToken: string): Promise<MediaItem[]> => { private formatDate = (): string => {
const headers = Object.assign(this.baseHeaders, { authorization: `Bearer ${accessToken}`, 'X-Goog-Request-Time': `${Date.now()}` }) 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')
function formatDate(): string { return year + month + day
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 public getHome = async () => {
} const headers = Object.assign(this.BASEHEADERS, { authorization: `Bearer ${this.accessToken}`, 'X-Goog-Request-Time': `${Date.now()}` })
const response = await fetch(`https://music.youtube.com/youtubei/v1/browse?alt=json`, { const response = await fetch(`https://music.youtube.com/youtubei/v1/browse?alt=json`, {
headers, headers,
@@ -244,7 +260,7 @@ export class YouTubeMusic {
context: { context: {
client: { client: {
clientName: 'WEB_REMIX', clientName: 'WEB_REMIX',
clientVersion: '1.' + formatDate() + '.01.00', clientVersion: '1.' + this.formatDate() + '.01.00',
hl: 'en', hl: 'en',
}, },
}, },
@@ -273,15 +289,52 @@ export class YouTubeMusic {
} }
} }
console.log(JSON.stringify(home)) const youtube = google.youtube('v3')
const videoInfo = await youtube.videos.list({
id: home.map((video) => video.id),
part: ['contentDetails', 'snippet', 'statistics'],
access_token: this.accessToken,
})
console.log(JSON.stringify(videoInfo))
// console.log(JSON.stringify(results[0].musicCarouselShelfRenderer.contents[0]))
// const sectionList = data.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer // const sectionList = data.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer
// if ('continuations' in sectionList) { // if ('continuations' in sectionList) {
// } // }
// const song: Song = {
// connectionId: this.connectionId,
// serviceType: 'youtube-music',
// type: 'song',
// id: home[0].id,
// name: home[0].name,
// duration:
// }
// return home // return home
} }
public getSong = async (videoId: string) => {
const headers = Object.assign(this.BASEHEADERS, { authorization: `Bearer ${this.accessToken}`, 'X-Goog-Request-Time': `${Date.now()}` })
const response = await fetch(`https://music.youtube.com/youtubei/v1/player?alt=json`, {
headers,
method: 'POST',
body: JSON.stringify({
playbackContext: {
contentPlaybackContext: { signatureTimestamp: }
},
context: {
client: {
clientName: 'WEB_REMIX',
clientVersion: '1.' + this.formatDate() + '.01.00',
hl: 'en',
},
},
}),
})
}
} }
class Parsers { class Parsers {

View File

@@ -13,7 +13,8 @@ export const GET: RequestHandler = async ({ params }) => {
connection.service = await Jellyfin.fetchSerivceInfo(connection.service.userId, connection.service.urlOrigin, connection.tokens.accessToken) connection.service = await Jellyfin.fetchSerivceInfo(connection.service.userId, connection.service.urlOrigin, connection.tokens.accessToken)
break break
case 'youtube-music': case 'youtube-music':
connection.service = await YouTubeMusic.fetchServiceInfo(connection.service.userId, connection.tokens.accessToken) const youTubeMusic = new YouTubeMusic(connection)
connection.service = await youTubeMusic.fetchServiceInfo()
break break
} }
} }

View File

@@ -35,7 +35,8 @@ export const GET: RequestHandler = async ({ params, fetch }) => {
for (const song of mostPlayedData.Items) recommendations.push(Jellyfin.songFactory(song, connection)) for (const song of mostPlayedData.Items) recommendations.push(Jellyfin.songFactory(song, connection))
break break
case 'youtube-music': case 'youtube-music':
YouTubeMusic.getHome(tokens.accessToken) const youtubeMusic = new YouTubeMusic(connection)
youtubeMusic.getHome()
break break
} }
} }

View File

@@ -43,11 +43,7 @@ export const actions: Actions = {
return fail(400, { message: 'Could not reach Jellyfin server' }) return fail(400, { message: 'Could not reach Jellyfin server' })
} }
const newConnectionId = Connections.addConnection('jellyfin', { const newConnectionId = Connections.addConnection('jellyfin', locals.user.id, { userId: authData.User.Id, urlOrigin: serverUrl.toString() }, { accessToken: authData.AccessToken })
userId: locals.user.id,
service: { userId: authData.User.Id, urlOrigin: serverUrl.toString() },
tokens: { accessToken: authData.AccessToken },
})
const response = await fetch(`/api/connections?ids=${newConnectionId}`, { const response = await fetch(`/api/connections?ids=${newConnectionId}`, {
method: 'GET', method: 'GET',
@@ -68,11 +64,12 @@ export const actions: Actions = {
const userChannelResponse = await youtube.channels.list({ mine: true, part: ['id', 'snippet'], access_token: tokens.access_token! }) const userChannelResponse = await youtube.channels.list({ mine: true, part: ['id', 'snippet'], access_token: tokens.access_token! })
const userChannel = userChannelResponse.data.items![0] const userChannel = userChannelResponse.data.items![0]
const newConnectionId = Connections.addConnection('youtube-music', { const newConnectionId = Connections.addConnection(
userId: locals.user.id, 'youtube-music',
service: { userId: userChannel.id! }, locals.user.id,
tokens: { accessToken: tokens.access_token!, refreshToken: tokens.refresh_token!, expiry: tokens.expiry_date! }, { userId: userChannel.id! },
}) { accessToken: tokens.access_token!, refreshToken: tokens.refresh_token!, expiry: tokens.expiry_date! },
)
const response = await fetch(`/api/connections?ids=${newConnectionId}`, { const response = await fetch(`/api/connections?ids=${newConnectionId}`, {
method: 'GET', method: 'GET',