Working on ytmusic parsers
This commit is contained in:
31
src/app.d.ts
vendored
31
src/app.d.ts
vendored
@@ -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.
@@ -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 => {}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user