From f38503256591d2d6b76231a2ada66b25ecc4d63f Mon Sep 17 00:00:00 2001 From: aria Date: Thu, 18 Jul 2024 20:08:59 -0400 Subject: [PATCH] add account checking to lucida, bugfix soundcloud --- package.json | 4 ++-- pnpm-lock.yaml | 18 +++++++++------ src/streamers/qobuz/main.ts | 37 +++++++++++++++++++++++++++---- src/streamers/soundcloud/main.ts | 31 +++++++++++++++++--------- src/streamers/spotify/main.ts | 10 ++++++++- src/streamers/spotify/parse.ts | 11 ++++++--- src/streamers/tidal/constants.ts | 1 + src/streamers/tidal/main.ts | 38 ++++++++++++++++++++++++++++---- src/types.ts | 6 +++-- 9 files changed, 123 insertions(+), 33 deletions(-) diff --git a/package.json b/package.json index 610eac7..075d874 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lucida", - "version": "2.0.0-11", + "version": "2.0.0-12", "description": "A modular music downloader tool", "main": "build/index.js", "type": "module", @@ -30,7 +30,7 @@ "license": "OQL", "dependencies": { "image-size": "^1.1.1", - "librespot": "^0.2.0", + "librespot": "^0.2.3", "node-fetch": "^3.3.2", "xmldom-qsa": "^1.1.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2b9c90..623705b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: ^1.1.1 version: 1.1.1 librespot: - specifier: ^0.2.0 - version: 0.2.0 + specifier: ^0.2.3 + version: 0.2.3 node-fetch: specifier: ^3.3.2 version: 3.3.2 @@ -26,7 +26,7 @@ importers: version: 20.11.17 '@typescript-eslint/eslint-plugin': specifier: ^6.21.0 - version: 6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.56.0)(typescript@5.3.3) + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.3.3))(eslint@8.56.0)(typescript@5.3.3) '@typescript-eslint/parser': specifier: ^6.21.0 version: 6.21.0(eslint@8.56.0)(typescript@5.3.3) @@ -462,8 +462,8 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - librespot@0.2.0: - resolution: {integrity: sha512-v8k10GLVIdinAhYExnvDwkpXCXAp9VnNijcEtt9eLQxT8gd2Yym2xlUUppL0Lb3oAYz5/+Q3aP3H73dipvtumA==} + librespot@0.2.3: + resolution: {integrity: sha512-EEGfAqClwLcfwiLvYiXbQyeLqk2Ownb9a+EuvsQwuuRKGbDxpcwlIgZBkU89Q1oLFmLKoY5rf+zgmOFTUXoTUA==} locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} @@ -766,7 +766,7 @@ snapshots: '@types/semver@7.5.6': {} - '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.56.0)(typescript@5.3.3)': + '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.3.3))(eslint@8.56.0)(typescript@5.3.3)': dependencies: '@eslint-community/regexpp': 4.10.0 '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.3.3) @@ -781,6 +781,7 @@ snapshots: natural-compare: 1.4.0 semver: 7.6.0 ts-api-utils: 1.2.1(typescript@5.3.3) + optionalDependencies: typescript: 5.3.3 transitivePeerDependencies: - supports-color @@ -793,6 +794,7 @@ snapshots: '@typescript-eslint/visitor-keys': 6.21.0 debug: 4.3.4 eslint: 8.56.0 + optionalDependencies: typescript: 5.3.3 transitivePeerDependencies: - supports-color @@ -809,6 +811,7 @@ snapshots: debug: 4.3.4 eslint: 8.56.0 ts-api-utils: 1.2.1(typescript@5.3.3) + optionalDependencies: typescript: 5.3.3 transitivePeerDependencies: - supports-color @@ -825,6 +828,7 @@ snapshots: minimatch: 9.0.3 semver: 7.6.0 ts-api-utils: 1.2.1(typescript@5.3.3) + optionalDependencies: typescript: 5.3.3 transitivePeerDependencies: - supports-color @@ -1138,7 +1142,7 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - librespot@0.2.0: + librespot@0.2.3: dependencies: fast-xml-parser: 4.3.4 protobufjs: 7.2.6 diff --git a/src/streamers/qobuz/main.ts b/src/streamers/qobuz/main.ts index c67f8d6..8e10649 100644 --- a/src/streamers/qobuz/main.ts +++ b/src/streamers/qobuz/main.ts @@ -28,6 +28,28 @@ interface QobuzOptions { token?: string } +interface LoginResponse { + user: { + id: number + display_name: string + language_code: string + zone: string, + store: string + country: string + creation_date: string + credential: { + parameters: { + lossy_streaming: boolean + lossless_streaming: boolean + hires_streaming: boolean + } + label: string + source: string + } + } + user_auth_token: string +} + export default class Qobuz implements StreamerWithLogin { hostnames = ['play.qobuz.com', 'open.qobuz.com', 'www.qobuz.com', 'qobuz.com'] token?: string @@ -100,9 +122,6 @@ export default class Qobuz implements StreamerWithLogin { app_id: this.appId } - interface LoginResponse { - user_auth_token: string - } const loginResponse = await this.#getSigned('user/login', params) this.token = loginResponse.user_auth_token } @@ -148,7 +167,7 @@ export default class Qobuz implements StreamerWithLogin { await this.#getSigned('track/getFileUrl', params) ) if (trackFileResponse.sample == true) - throw new Error('Could not get non-sample file. Check your token.') + throw new Error(`Could not get non-sample file. Make sure the track isn't download-only.`) const streamResponse = await fetch(trackFileResponse.url) return { mimeType: trackFileResponse.mime_type, @@ -252,4 +271,14 @@ export default class Qobuz implements StreamerWithLogin { throw new Error('URL unrecognised') } } + async getAccountInfo(): Promise { + const loginResponse = await this.#getSigned('user/login', {extra: 'partner', device_manufacturer_id: 'undefined', app_id: this.appId}) + + return { + valid: true, + premium: loginResponse.user.credential.parameters.hires_streaming, + country: loginResponse.user.country, + explicit: true + } + } } diff --git a/src/streamers/soundcloud/main.ts b/src/streamers/soundcloud/main.ts index 000590c..5c9c495 100644 --- a/src/streamers/soundcloud/main.ts +++ b/src/streamers/soundcloud/main.ts @@ -5,7 +5,9 @@ import { Streamer, SearchResults, GetByUrlResponse, - GetStreamResponse + GetStreamResponse, + StreamerAccount, + TrackGetByUrlResponse } from '../../types.js' import { parseAlbum, @@ -118,15 +120,14 @@ export default class Soundcloud implements Streamer { } #formatURL(og: string, client: ScClient): string { - let url = og const parsed = new URL(og) - if (!parsed.searchParams.get('user_id')) url = url + `&user_id=${client.anonId}` - if (!parsed.searchParams.get('client_id')) url = url + `&client_id=${client.id}` - if (!parsed.searchParams.get('app_version')) url = url + `&app_version=${client.version}` - if (!parsed.searchParams.get('app_locale')) url = url + `&app_locale=en` + if (client.anonId) parsed.searchParams.append('user_id', client.anonId) + if (client.id) parsed.searchParams.append('client_id', client.id) + if (client.version) parsed.searchParams.append('app_version', client.version) + if (!parsed.searchParams.get('app_locale')) parsed.searchParams.append('app_locale', 'en') - return url + return parsed.href } async #getMetadata(url: string): Promise { @@ -141,8 +142,9 @@ export default class Soundcloud implements Streamer { switch (type) { case 'track': { const trackId = html - .split(``)?.[0] + let naked = `https://api-v2.soundcloud.com/tracks/${trackId}` let path = new URL(url).pathname @@ -157,7 +159,7 @@ export default class Soundcloud implements Streamer { ).text() ) - return { + return { type: 'track', getStream: async (hq?: boolean) => { if (!hq) hq = false @@ -225,7 +227,7 @@ export default class Soundcloud implements Streamer { async #getRawTrackInfo(id: number | string, client: ScClient) { const api = JSON.parse( await ( - await fetch(this.#formatURL(`https://api-v2.soundcloud.com/tracks?ids=${id}`, client), { + await fetch(this.#formatURL(`hhttps://api-v2.soundcloud.com/tracks/${id}`, client), { method: 'get', headers: headers(this.oauthToken) }) @@ -234,6 +236,15 @@ export default class Soundcloud implements Streamer { return api } + async getAccountInfo(): Promise { + const track = await this.getByUrl('https://soundcloud.com/ween/polka-dot-tail') + const stream = await track.getStream() + if (stream.mimeType.startsWith('audio/mp4')) { + stream.stream.unpipe() + return {valid: true, premium: true, explicit: true} + } + else return {valid: true, premium: false, explicit: true} + } } async function fetchKey(response: string) { diff --git a/src/streamers/spotify/main.ts b/src/streamers/spotify/main.ts index 0fd7cb0..20df644 100644 --- a/src/streamers/spotify/main.ts +++ b/src/streamers/spotify/main.ts @@ -108,9 +108,17 @@ class Spotify implements StreamerWithLogin { } } async getAccountInfo(): Promise { + const info = await this.client.get.me() + + let premium + if (info.plan == 'premium') premium = true + else premium = false + return { valid: true, - premium: (await this.client.isPremium()) + premium, + country: info.country, + explicit: info.allowExplicit } } disconnect() { diff --git a/src/streamers/spotify/parse.ts b/src/streamers/spotify/parse.ts index ccaa02c..cdf5dd7 100644 --- a/src/streamers/spotify/parse.ts +++ b/src/streamers/spotify/parse.ts @@ -36,12 +36,13 @@ export function parseTrack(raw: SpotifyTrack) { durationMs: raw.durationMs, } if (raw.album) track.album = parseAlbum(raw.album) - //if (raw?.isrc) track.isrc = raw.isrc + if (raw?.isrc) track.isrc = raw.isrc + if (raw?.album?.availableMarkets) track.regions = raw.album.availableMarkets return track } -export function parseAlbum(raw: SpotifyAlbum): Album { - return { +export function parseAlbum(raw: SpotifyAlbum) { + const album: Album = { title: raw.name, id: raw.id, url: raw.externalUrl, @@ -50,6 +51,10 @@ export function parseAlbum(raw: SpotifyAlbum): Album { coverArtwork: parseThumbnails(raw.coverArtwork), artists: raw.artists.map((e) => parseArtist(e)), } + + if (raw.availableMarkets) album.regions = raw.availableMarkets + + return album } export function parseEpisode(raw: SpotifyEpisode) { diff --git a/src/streamers/tidal/constants.ts b/src/streamers/tidal/constants.ts index 3eb029a..394d959 100644 --- a/src/streamers/tidal/constants.ts +++ b/src/streamers/tidal/constants.ts @@ -1,2 +1,3 @@ export const TIDAL_AUTH_BASE = 'https://auth.tidal.com/v1/' export const TIDAL_API_BASE = 'https://listen.tidal.com/v1/' +export const TIDAL_SUBSCRIPTION_BASE = 'https://api.tidal.com/v1/' diff --git a/src/streamers/tidal/main.ts b/src/streamers/tidal/main.ts index b64a8da..4e64736 100644 --- a/src/streamers/tidal/main.ts +++ b/src/streamers/tidal/main.ts @@ -6,9 +6,10 @@ import { GetStreamResponse, SearchResults, Streamer, - Track + Track, + StreamerAccount } from '../../types.js' -import { TIDAL_AUTH_BASE, TIDAL_API_BASE } from './constants.js' +import { TIDAL_AUTH_BASE, TIDAL_API_BASE, TIDAL_SUBSCRIPTION_BASE } from './constants.js' import { Contributor, RawAlbum, @@ -40,6 +41,22 @@ interface LoginData { interface SessionData { countryCode: string + userId: number +} + +interface SubscriptionData { + startDate: string + validUntil: string + status: string + subscription: { + type: string + offlineGracePeriod: number + } + highestSoundQuality: string + premiumAccess: boolean + canGetTrial: boolean + paymentType: string, + paymentOverdue: boolean } export default class Tidal implements Streamer { @@ -49,6 +66,7 @@ export default class Tidal implements Streamer { refreshToken: string expires: number countryCode: string + userId: number | undefined hostnames = ['tidal.com', 'www.tidal.com', 'listen.tidal.com'] failedAuth = false constructor(options: TidalOptions) { @@ -77,7 +95,7 @@ export default class Tidal implements Streamer { 'User-Agent': 'TIDAL_ANDROID/1039 okhttp/3.14.9' } } - async #get(url: string, params: { [key: string]: string | number } = {}): Promise { + async #get(url: string, params: { [key: string]: string | number } = {}, base: string = TIDAL_API_BASE): Promise { if (this.failedAuth) throw new Error(`Last request failed to authorize, get new tokens`) if (Date.now() > this.expires) await this.refresh() if (!this.countryCode) await this.getCountryCode() @@ -88,7 +106,7 @@ export default class Tidal implements Streamer { if (typeof params[key] == 'number') params[key] = params[key].toString() } const response = await fetch( - `${TIDAL_API_BASE}${url}?${new URLSearchParams(<{ [key: string]: string }>params)}`, + `${base}${url}?${new URLSearchParams(<{ [key: string]: string }>params)}`, { headers: this.headers() } @@ -127,6 +145,7 @@ export default class Tidal implements Streamer { if (sessionResponse.status != 200) return false const sessionData = await sessionResponse.json() this.countryCode = sessionData.countryCode + this.userId = sessionData.userId return true } async getTokens() { @@ -392,4 +411,15 @@ export default class Tidal implements Streamer { } } } + async getAccountInfo(): Promise { + if (!this.userId) await this.getCountryCode() + const subscription = await this.#get(`users/${this.userId}/subscription`, {}, TIDAL_SUBSCRIPTION_BASE) + + return { + valid: true, + premium: subscription.premiumAccess, + country: this.countryCode, + explicit: true + } + } } diff --git a/src/types.ts b/src/types.ts index 5923567..c14af20 100644 --- a/src/types.ts +++ b/src/types.ts @@ -32,6 +32,7 @@ export interface Album { copyright?: string, label?: string, genre?: string[] + regions?: string[] } export interface Track { @@ -52,9 +53,9 @@ export interface Track { album?: Album durationMs?: number coverArtwork?: CoverArtwork[] - regions?: Region[] genres?: string[] releaseDate?: Date + regions?: string[] description?: string } @@ -134,6 +135,7 @@ export interface StreamerAccount { valid: boolean premium?: boolean country?: string + explicit?: boolean } export interface Streamer { @@ -144,7 +146,7 @@ export interface Streamer { | ((url: string) => Promise) | ((url: string, limit?: number) => Promise) disconnect?(): Promise - getAccountInfo?(): Promise + getAccountInfo(): Promise } export interface StreamerWithLogin extends Streamer {