diff --git a/.dockerignore b/.dockerignore index 480cd4e7..c66c9e48 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,12 +1,16 @@ node_modules npm-debug.log +dist logs +.cache .git .github .vscode +.husky Dockerfile docker-compose.yml README.md LICENSE biome.json -commitlint.config.mjs \ No newline at end of file +commitlint.config.mjs +sea-config.json \ No newline at end of file diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index ab79078e..cc73fba8 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -1,47 +1,58 @@ name: Docker Image CI on: + push: + branches: ['**'] release: types: [created] +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + jobs: build: - name: Publish Docker image + name: Build and push Docker image runs-on: ubuntu-latest + permissions: + contents: read + packages: write steps: - name: Checkout uses: actions/checkout@v4 - - name: Get package version - id: package-version - uses: martinbeentjes/npm-get-version-action@v1.3.1 - - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Generate Docker meta + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate Docker metadata id: meta uses: docker/metadata-action@v5 with: - images: ${{ secrets.DOCKERHUB_USERNAME }}/nodelink + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | - type=raw,value=${{ steps.package-version.outputs.current-version}} - type=raw,value=latest - - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + type=ref,event=branch + type=sha,prefix= + type=semver,pattern={{version}} + type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'main') || github.event_name == 'release' }} - - name: Build and Push + - name: Build and push uses: docker/build-push-action@v5 with: + context: . push: true - platforms: linux/amd64, linux/arm64 + platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile index a16c288f..25bb22d9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,46 +1,30 @@ -# Stage 1: Builder - Install dependencies +# Stage 1: Builder - Install dependencies and bundle FROM node:25-alpine AS builder -# Install git (required for npm to install dependencies from GitHub) RUN apk add --no-cache git -# Set working directory WORKDIR /app -# Copy package.json and package-lock.json (if available) to leverage Docker cache -# Use wildcards to ensure both package.json and package-lock.json (or yarn.lock/pnpm-lock.yaml) are copied COPY package.json ./ - -# Install production dependencies -# This command automatically handles package-lock.json if it exists, otherwise it creates one. -# For Bun, you might use 'bun install --production'. RUN npm install -# Stage 2: Runner - Copy application code and run -FROM node:25-alpine - -# Set working directory -WORKDIR /app - -# Copy production dependencies from the builder stage -COPY --from=builder /app/node_modules ./node_modules - -# Copy the rest of the application source code -# This includes the 'src' directory, default config, and package files for runtime information. COPY src/ ./src/ +COPY scripts/ ./scripts/ COPY config.default.js ./config.default.js -COPY package.json ./package.json +COPY plugins/ ./plugins/ + +RUN npm install --no-save esbuild && BUNDLE_ONLY=1 node scripts/build.js -# Expose the port the application listens on (default is 3000 from config.default.js) -EXPOSE 3000 +# Stage 2: Runner - Minimal image with bundled output +FROM node:25-alpine + +WORKDIR /app -# Set environment variables for configuration -# These can be overridden via docker-compose.yml or 'docker run -e' -# Example: NODELINK_SERVER_PASSWORD=your_secure_password -ENV NODELINK_SERVER_PORT=3000 \ - NODELINK_SERVER_HOST=0.0.0.0 \ - NODELINK_CLUSTER_ENABLED=true +# Copy bundled application and native modules from builder +COPY --from=builder /app/src ./src/ +COPY --from=builder /app/dist/ ./dist/ +COPY --from=builder /app/config.default.js ./config.default.js +COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/node_modules ./node_modules/ -# Command to run the application -# It uses the 'start' script defined in package.json -CMD ["npm", "start"] +CMD ["node", "--dns-result-order=ipv4first", "--openssl-legacy-provider", "dist/main.mjs"] diff --git a/config.default.js b/config.default.js index b91d7e9a..fe3e158c 100644 --- a/config.default.js +++ b/config.default.js @@ -377,6 +377,41 @@ export default { artistLoadLimit: 1, // 0 = no limit, 1 = 10 tracks, 2 = 20 tracks, etc. albumLoadLimit: 1, // 0 = no limit, 1 = 50 tracks, 2 = 100 tracks, etc. playlistLoadLimit: 1 // 0 = no limit, 1 = 100 tracks, 2 = 200 tracks, etc. + }, + rip: { + enabled: false, + urlPattern: '', // Regex with named groups "type" and "identifier" for matching URLs + searchPrefix: '', // Search prefix (e.g. "ripsearch") + recommendationPrefix: '', // Recommendation prefix (e.g. "riprec") + linkBase: '', // Base URL for links + devLinkBase: '', // Dev base URL for links (optional fallback) + privateApiBase: '', // Primary API base URL + devApiBase: '', // Dev API base URL (optional fallback) + searchApiBase: '', // Search API base URL + cdnBase: '', // CDN base URL for artwork etc. + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + typeMap: {}, // Maps URL type capture group values to internal types (album, playlist, artist, track) + apiPaths: { // API path templates with {id}, {query}, {limit} placeholders + track: '', + album: '', + playlist: '', + playlistTracks: '', + artistProfile: '', + play: '', + search: '' + }, + linkPaths: { // Link path templates appended to linkBase, with {id} placeholder + track: '', + album: '', + artist: '', + playlist: '' + }, + cdnPaths: { // CDN path templates appended to cdnBase, with {id}, {thumbnail} placeholders + playlistArt: '' + }, + playBody: {}, // Request body sent to the play endpoint + streamParamName: '', // Query param name to extract from stream URL and re-send as cookie + fields: {} // Response field name mappings (string for dot-path, array for fallback dot-paths) } }, lyrics: { @@ -509,6 +544,25 @@ export default { maxLayersMix: 5, autoCleanup: true }, + externalApi: { + enabled: false, + baseUrl: '', + authorization: '', + deezer: { // Endpoints: GET /api/deezer/arls (returns { arls: [{ arl: 'arl_value', expires_at: 'iso_ts', license: 'license_value', api_key: 'api_key_value' } ...] }) and POST /api/deezer/report (body { arl: 'arl_value' }) + enabled: false, + failureThreshold: 3, // Remove ARL from rotation after this many consecutive failures + refreshIntervalMs: 60 * 60 * 1000, // Refetch ARLs from API interval + poolMinSize: 2 // Trigger early refetch when pool drops below this size + }, + youtube: { // Endpoint: GET /api/youtube/tokens (returns { tokens: [{ access_token: 'token_value', refresh_token: 'refresh_token_value', token_type: 'Bearer', expires_at: 'iso_ts' }, ...] }) and POST /api/youtube/tokens (body { access_token: 'token_value' }) + enabled: false, + refreshIntervalMs: 20 * 60 * 60 * 1000 // Refetch tokens interval + }, + rip: { // Endpoint: GET /api/rip/tokens (returns { tokens: [{ access_token: '', expires_at: '', api_url: '', environment: '' }, ...] }) + enabled: false, + refreshIntervalMs: 30 * 60 * 1000 // Refetch tokens interval (30 min) + } + }, plugins: [ /* { name: 'nodelink-sample-plugin', diff --git a/docker-compose.yml b/docker-compose.yml index a86dba3c..f4e4ef2f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -254,6 +254,7 @@ services: # NODELINK_MIX_AUTOCLEANUP: "true" # volumes: + # - ./config.js:/app/config.js # - ./local-music:/app/local-music # - ./logs:/app/logs # - ./.cache:/app/.cache diff --git a/scripts/build.js b/scripts/build.js index 657f3f21..c17fcbec 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -41,7 +41,7 @@ await esbuild.build({ platform: 'node', target: 'node20', outfile: path.join(distDir, 'main.mjs'), - external: ['bufferutil', 'utf-8-validate', '@toddynnn/symphonia-decoder', 'toddy-mediaplex'], + external: ['bufferutil', 'utf-8-validate', '@toddynnn/symphonia-decoder', 'toddy-mediaplex', 'jsdom'], format: 'esm', keepNames: true, loader: { '.node': 'file' }, @@ -101,6 +101,11 @@ if (fs.existsSync(mediaplexPkgDir)) { } } +if (process.env.BUNDLE_ONLY) { + console.log('Bundle-only mode: skipping SEA binary creation.') + process.exit(0) +} + const filesToEmbed = {} function scanDir(dir, base = '') { for (const file of fs.readdirSync(dir)) { diff --git a/src/index.js b/src/index.js index b35d91dc..002504e3 100644 --- a/src/index.js +++ b/src/index.js @@ -29,6 +29,7 @@ import { import 'dotenv/config' import { GatewayEvents } from './constants.js' import DosProtectionManager from './managers/dosProtectionManager.js' +import ExternalApiManager from './managers/externalApiManager.js' import PlayerManager from './managers/playerManager.js' import PluginManager from './managers/pluginManager.js' import RateLimitManager from './managers/rateLimitManager.js' @@ -174,6 +175,7 @@ class NodelinkServer extends EventEmitter { this.statsManager = new statsManager(this) this.rateLimitManager = new RateLimitManager(this) this.dosProtectionManager = new DosProtectionManager(this) + this.externalApiManager = new ExternalApiManager(this) this.pluginManager = new PluginManager(this) this.sourceWorkerManager = isClusterPrimary && options.cluster?.specializedSourceWorker?.enabled @@ -1527,6 +1529,7 @@ class NodelinkServer extends EventEmitter { await this.credentialManager.load() await this.trackCacheManager.load() await this.statsManager.initialize() + await this.externalApiManager.initialize() // Ensure sources are initialized before proceeding if (this._sourceInitPromise) await this._sourceInitPromise diff --git a/src/managers/externalApiManager.js b/src/managers/externalApiManager.js new file mode 100644 index 00000000..45cc64bf --- /dev/null +++ b/src/managers/externalApiManager.js @@ -0,0 +1,473 @@ +import { logger, makeRequest } from '../utils.js' + +export default class ExternalApiManager { + constructor(nodelink) { + this.nodelink = nodelink + this.config = nodelink.options.externalApi + + // Deezer state + this.deezerArls = [] + this.deezerArlIndex = 0 + this.deezerArlFailures = new Map() + this.deezerSessions = new Map() + + // YouTube state + this.youtubeTokens = [] + this.youtubeTokenIndex = 0 + this.youtubeTokenFailures = new Map() + + // Rip state + this.ripTokens = [] + this._ripFetching = false + this._ripRefreshTimer = null + + // Timers + this._deezerRefreshTimer = null + this._youtubeRefreshTimer = null + + // Prevent concurrent refetch + this._deezerFetching = false + this._youtubeFetching = false + } + + get enabled() { + return !!this.config?.enabled + } + + get deezerEnabled() { + return this.enabled && !!this.config?.deezer?.enabled + } + + get youtubeEnabled() { + return this.enabled && !!this.config?.youtube?.enabled + } + + get ripEnabled() { + return this.enabled && !!this.config?.rip?.enabled + } + + _getHeaders() { + const headers = {} + if (this.config.authorization) { + headers['Authorization'] = this.config.authorization + } + return headers + } + + async initialize() { + if (!this.enabled) return + + if (!this.config.baseUrl) { + logger('error', 'ExternalAPI', 'External API enabled but no baseUrl configured.') + return + } + + if (this.deezerEnabled) { + await this.fetchDeezerArls() + this._startDeezerRefreshTimer() + } + + if (this.youtubeEnabled) { + await this.fetchYoutubeTokens() + this._startYoutubeRefreshTimer() + } + + if (this.ripEnabled) { + await this.fetchRipTokens() + this._startRipRefreshTimer() + } + } + + // ---- Deezer ---- + + async fetchDeezerArls() { + if (this._deezerFetching) return + this._deezerFetching = true + + try { + const url = `${this.config.baseUrl}/api/deezer/arls` + const { body, error, statusCode } = await makeRequest(url, { + method: 'GET', + headers: this._getHeaders() + }) + + if (error || statusCode !== 200 || !body?.arls || !Array.isArray(body.arls)) { + logger('error', 'ExternalAPI', `Failed to fetch Deezer ARLs: ${error?.message || `status ${statusCode}`}`) + return + } + + const now = Date.now() + let added = 0 + + for (const entry of body.arls) { + if (!entry?.arl || typeof entry.arl !== 'string') continue + if (!entry?.api_key || typeof entry.api_key !== 'string') continue + + // check if license is a JSON string containing "license_token" (possibly nested) + if (entry?.license && typeof entry.license === 'string' && entry.license.startsWith('{')) { + try { + const parsed = JSON.parse(entry.license) + const token = parsed.license_token || parsed.LICENCE?.OPTIONS?.license_token + + if (token) { + entry.license = token + } else { + logger('warn', 'ExternalAPI', `Deezer ARL entry has invalid license format, missing "license_token" field: ${entry.arl.slice(0, 8)}...`) + continue + } + } catch (e) { + logger('warn', 'ExternalAPI', `Deezer ARL entry has invalid license format, not a valid JSON: ${entry.arl.slice(0, 8)}...`) + continue + } + } + + if (!entry?.license || typeof entry.license !== 'string') { + if (this.nodelink.options.sources.deezer.decryptionKey) { + entry.license = this.nodelink.options.sources.deezer.decryptionKey + } else { + continue + } + } + + // Skip expired entries + if (entry.expires_at && new Date(entry.expires_at).getTime() <= now) continue + + if (!this.deezerSessions.has(entry.arl)) { + this.deezerSessions.set(entry.arl, { + licenseToken: entry.license, + csrfToken: entry.api_key, + cookie: `arl=${entry.arl}`, + expiresAt: entry.expires_at ? new Date(entry.expires_at).getTime() : null + }) + + if (!this.deezerArls.includes(entry.arl)) { + this.deezerArls.push(entry.arl) + added++ + } + } + } + + logger('info', 'ExternalAPI', `Fetched Deezer ARLs from external API (${added} new). Pool size: ${this.deezerArls.length}`) + } catch (e) { + logger('error', 'ExternalAPI', `Error fetching Deezer ARLs: ${e.message}`) + } finally { + this._deezerFetching = false + } + } + + getDeezerSession() { + if (this.deezerArls.length === 0) return null + + // Remove expired sessions lazily + const now = Date.now() + while (this.deezerArls.length > 0) { + const idx = this.deezerArlIndex % this.deezerArls.length + const arl = this.deezerArls[idx] + const session = this.deezerSessions.get(arl) + + if (!session || (session.expiresAt && session.expiresAt <= now)) { + this._removeDeezerArl(arl) + continue + } + + this.deezerArlIndex = (idx + 1) % this.deezerArls.length + return { arl, ...session } + } + + return null + } + + _removeDeezerArl(arl) { + this.deezerArls = this.deezerArls.filter(a => a !== arl) + this.deezerSessions.delete(arl) + this.deezerArlFailures.delete(arl) + + if (this.deezerArls.length > 0) { + this.deezerArlIndex = this.deezerArlIndex % this.deezerArls.length + } else { + this.deezerArlIndex = 0 + } + } + + async reportDeezerArlFailure(arl) { + const count = (this.deezerArlFailures.get(arl) || 0) + 1 + this.deezerArlFailures.set(arl, count) + + const threshold = this.config.deezer?.failureThreshold || 3 + + logger('warn', 'ExternalAPI', `Deezer ARL ${arl.slice(0, 8)}... failure count: ${count}/${threshold}`) + + if (count >= threshold) { + this._removeDeezerArl(arl) + + logger('warn', 'ExternalAPI', `Removed Deezer ARL ${arl.slice(0, 8)}... from rotation. Pool size: ${this.deezerArls.length}`) + + await this._reportDeezerArlToApi(arl) + this._checkDeezerPool() + } + } + + async _reportDeezerArlToApi(arl) { + try { + const url = `${this.config.baseUrl}/api/deezer/report` + const { error, statusCode } = await makeRequest(url, { + method: 'POST', + headers: this._getHeaders(), + body: { arl } + }) + + if (error || (statusCode !== 200 && statusCode !== 204)) { + logger('warn', 'ExternalAPI', `Failed to report Deezer ARL failure: ${error?.message || `status ${statusCode}`}`) + } else { + logger('info', 'ExternalAPI', `Reported Deezer ARL failure to external API: ${arl.slice(0, 8)}...`) + } + } catch (e) { + logger('warn', 'ExternalAPI', `Error reporting Deezer ARL failure: ${e.message}`) + } + } + + _checkDeezerPool() { + const poolMinSize = this.config.deezer?.poolMinSize || 2 + + if (this.deezerArls.length < poolMinSize) { + logger('info', 'ExternalAPI', `Deezer ARL pool below minimum (${this.deezerArls.length}/${poolMinSize}), fetching new ARLs...`) + this.fetchDeezerArls() + } + } + + _startDeezerRefreshTimer() { + const refreshInterval = this.config.deezer?.refreshIntervalMs || 60 * 60 * 1000 + + this._deezerRefreshTimer = setInterval(() => { + this.fetchDeezerArls() + }, refreshInterval) + } + + // ---- YouTube ---- + + async fetchYoutubeTokens() { + if (this._youtubeFetching) return + this._youtubeFetching = true + + try { + const url = `${this.config.baseUrl}/api/youtube/tokens` + const { body, error, statusCode } = await makeRequest(url, { + method: 'GET', + headers: this._getHeaders() + }) + + if (error || statusCode !== 200 || !body?.tokens || !Array.isArray(body.tokens)) { + logger('error', 'ExternalAPI', `Failed to fetch YouTube tokens: ${error?.message || `status ${statusCode}`}`) + return + } + + const now = Date.now() + const validTokens = [] + + for (const entry of body.tokens) { + if (!entry?.access_token || typeof entry.access_token !== 'string') continue + + // Skip expired tokens + if (entry.expires_at && new Date(entry.expires_at).getTime() <= now) continue + + validTokens.push({ + accessToken: entry.access_token, + expiresAt: entry.expires_at ? new Date(entry.expires_at).getTime() : null + }) + } + + this.youtubeTokens = validTokens + this.youtubeTokenIndex = 0 + this.youtubeTokenFailures.clear() + + logger('info', 'ExternalAPI', `Fetched ${this.youtubeTokens.length} YouTube tokens from external API.`) + } catch (e) { + logger('error', 'ExternalAPI', `Error fetching YouTube tokens: ${e.message}`) + } finally { + this._youtubeFetching = false + } + } + + getYoutubeToken() { + if (this.youtubeTokens.length === 0) return null + + // Remove expired tokens lazily + const now = Date.now() + while (this.youtubeTokens.length > 0) { + const idx = this.youtubeTokenIndex % this.youtubeTokens.length + const entry = this.youtubeTokens[idx] + + if (entry.expiresAt && entry.expiresAt <= now) { + this.youtubeTokens.splice(idx, 1) + this.youtubeTokenFailures.delete(entry.accessToken) + if (this.youtubeTokens.length > 0) { + this.youtubeTokenIndex = this.youtubeTokenIndex % this.youtubeTokens.length + } else { + this.youtubeTokenIndex = 0 + } + continue + } + + this.youtubeTokenIndex = (idx + 1) % this.youtubeTokens.length + return entry.accessToken + } + + return null + } + + async reportYoutubeTokenFailure(accessToken) { + const count = (this.youtubeTokenFailures.get(accessToken) || 0) + 1 + this.youtubeTokenFailures.set(accessToken, count) + + const threshold = this.config.deezer?.failureThreshold || 3 + + logger('warn', 'ExternalAPI', `YouTube token ${accessToken.slice(0, 8)}... failure count: ${count}/${threshold}`) + + if (count >= threshold) { + this.youtubeTokens = this.youtubeTokens.filter(t => t.accessToken !== accessToken) + this.youtubeTokenFailures.delete(accessToken) + + if (this.youtubeTokens.length > 0) { + this.youtubeTokenIndex = this.youtubeTokenIndex % this.youtubeTokens.length + } else { + this.youtubeTokenIndex = 0 + } + + logger('warn', 'ExternalAPI', `Removed YouTube token ${accessToken.slice(0, 8)}... from rotation. Pool size: ${this.youtubeTokens.length}`) + + await this._reportYoutubeTokenToApi(accessToken) + this._checkYoutubePool() + } + } + + async _reportYoutubeTokenToApi(accessToken) { + try { + const url = `${this.config.baseUrl}/api/youtube/tokens` + const { error, statusCode } = await makeRequest(url, { + method: 'POST', + headers: this._getHeaders(), + body: { access_token: accessToken } + }) + + if (error || (statusCode !== 200 && statusCode !== 204)) { + logger('warn', 'ExternalAPI', `Failed to report YouTube token failure: ${error?.message || `status ${statusCode}`}`) + } else { + logger('info', 'ExternalAPI', `Reported YouTube token failure to external API: ${accessToken.slice(0, 8)}...`) + } + } catch (e) { + logger('warn', 'ExternalAPI', `Error reporting YouTube token failure: ${e.message}`) + } + } + + _checkYoutubePool() { + if (this.youtubeTokens.length === 0) { + logger('info', 'ExternalAPI', 'YouTube token pool empty, fetching new tokens...') + this.fetchYoutubeTokens() + } + } + + _startYoutubeRefreshTimer() { + const refreshInterval = this.config.youtube?.refreshIntervalMs || 20 * 60 * 60 * 1000 + + this._youtubeRefreshTimer = setInterval(() => { + this.fetchYoutubeTokens() + }, refreshInterval) + } + + // ---- Rip ---- + + async fetchRipTokens() { + if (this._ripFetching) return + this._ripFetching = true + + try { + const url = `${this.config.baseUrl}/api/rip/tokens` + const { body, error, statusCode } = await makeRequest(url, { + method: 'GET', + headers: this._getHeaders() + }) + + if (error || statusCode !== 200 || !body?.tokens || !Array.isArray(body.tokens)) { + logger('error', 'ExternalAPI', `Failed to fetch Rip tokens: ${error?.message || `status ${statusCode}`}`) + return + } + + const now = Date.now() + const validTokens = [] + + for (const entry of body.tokens) { + if (!entry?.access_token || typeof entry.access_token !== 'string') continue + if (!entry?.api_url) continue + + const expiresAt = entry.expires_at ? new Date(entry.expires_at).getTime() : null + if (expiresAt && expiresAt <= now) continue + + validTokens.push({ + token: entry.access_token, + expiresAt, + apiUrl: entry.api_url, + environment: entry.environment || '' + }) + } + + this.ripTokens = validTokens + + const devCount = validTokens.filter(t => t.environment === 'dev').length + logger('info', 'ExternalAPI', `Fetched ${validTokens.length} Rip tokens (main: ${validTokens.length - devCount}, dev: ${devCount}).`) + } catch (e) { + logger('error', 'ExternalAPI', `Error fetching Rip tokens: ${e.message}`) + } finally { + this._ripFetching = false + } + } + + getRipToken(type) { + const now = Date.now() + + // Filter valid, non-expired tokens + let candidates = this.ripTokens.filter(t => { + if (t.expiresAt && t.expiresAt <= now) return false + if (type === 'dev') return t.environment === 'dev' + if (type === 'main') return t.environment !== 'dev' + return true + }) + + if (candidates.length === 0) { + // Try any valid token + candidates = this.ripTokens.filter(t => !t.expiresAt || t.expiresAt > now) + } + + if (candidates.length === 0) { + // Trigger async refetch + this.fetchRipTokens() + return null + } + + return candidates[Math.floor(Math.random() * candidates.length)] + } + + _startRipRefreshTimer() { + const refreshInterval = this.config.rip?.refreshIntervalMs || 30 * 60 * 1000 + + this._ripRefreshTimer = setInterval(() => { + this.fetchRipTokens() + }, refreshInterval) + } + + // ---- Cleanup ---- + + stop() { + if (this._deezerRefreshTimer) { + clearInterval(this._deezerRefreshTimer) + this._deezerRefreshTimer = null + } + if (this._youtubeRefreshTimer) { + clearInterval(this._youtubeRefreshTimer) + this._youtubeRefreshTimer = null + } + if (this._ripRefreshTimer) { + clearInterval(this._ripRefreshTimer) + this._ripRefreshTimer = null + } + } +} diff --git a/src/sources/deezer.js b/src/sources/deezer.js index 9dbd45ee..5d5c31d2 100644 --- a/src/sources/deezer.js +++ b/src/sources/deezer.js @@ -30,9 +30,36 @@ export default class DeezerSource { this.licenseToken = null } + _getCurrentSession() { + if (this.nodelink.externalApiManager?.deezerEnabled) { + const session = this.nodelink.externalApiManager.getDeezerSession() + if (session) return session + } + return { + arl: null, + csrfToken: this.csrfToken, + licenseToken: this.licenseToken, + cookie: this.cookie + } + } + async setup() { logger('info', 'Sources', 'Initializing Deezer source...') + // External API mode: ARLs are managed externally + if (this.nodelink.externalApiManager?.deezerEnabled) { + const poolSize = this.nodelink.externalApiManager.deezerArls.length + if (poolSize > 0) { + const session = this.nodelink.externalApiManager.getDeezerSession() + if (session) { + this.licenseToken = session.licenseToken + this.cookie = session.cookie + } + } + logger('info', 'Sources', `Deezer source setup with external API (${poolSize} ARLs available).`) + return true + } + const cachedCsrf = this.nodelink.credentialManager.get('deezer_csrf_token') const cachedLicense = this.nodelink.credentialManager.get( 'deezer_license_token' @@ -180,11 +207,12 @@ export default class DeezerSource { } } + const recSession = this._getCurrentSession() const { body: result, error } = await makeRequest( - `https://www.deezer.com/ajax/gw-light.php?method=${method}&input=3&api_version=1.0&api_token=${this.csrfToken}`, + `https://www.deezer.com/ajax/gw-light.php?method=${method}&input=3&api_version=1.0&api_token=${recSession.csrfToken}`, { method: 'POST', - headers: { Cookie: this.cookie }, + headers: { Cookie: recSession.cookie }, body: payload, disableBodyCompression: true } @@ -403,13 +431,15 @@ export default class DeezerSource { if (cached) return cached } - if (this.licenseToken) { + const session = this._getCurrentSession() + + if (session.licenseToken) { try { const { body: trackData } = await makeRequest( - `https://www.deezer.com/ajax/gw-light.php?method=song.getListData&input=3&api_version=1.0&api_token=${this.csrfToken}`, + `https://www.deezer.com/ajax/gw-light.php?method=song.getListData&input=3&api_version=1.0&api_token=${session.csrfToken}`, { method: 'POST', - headers: { Cookie: this.cookie }, + headers: { Cookie: session.cookie }, body: { sng_ids: [decodedTrack.identifier] }, disableBodyCompression: true } @@ -430,7 +460,7 @@ export default class DeezerSource { { method: 'POST', body: { - license_token: this.licenseToken, + license_token: session.licenseToken, media: [ { type: 'FULL', @@ -473,6 +503,9 @@ export default class DeezerSource { } } } catch (e) { + if (session.arl && this.nodelink.externalApiManager?.deezerEnabled) { + this.nodelink.externalApiManager.reportDeezerArlFailure(session.arl) + } logger( 'warn', 'Deezer', diff --git a/src/sources/rip.js b/src/sources/rip.js new file mode 100644 index 00000000..6d0b4ddc --- /dev/null +++ b/src/sources/rip.js @@ -0,0 +1,523 @@ +import { PassThrough } from 'node:stream' +import { encodeTrack, http1makeRequest, logger, makeRequest, getBestMatch } from '../utils.js' + +export default class RipSource { + constructor(nodelink) { + this.nodelink = nodelink + this.config = nodelink.options + this.sourceConfig = nodelink.options.sources?.rip || {} + + const urlPattern = this.sourceConfig.urlPattern + this.patterns = urlPattern ? [new RegExp(urlPattern, 'i')] : [] + this.searchTerms = this.sourceConfig.searchPrefix ? [this.sourceConfig.searchPrefix] : [] + this.recommendationTerm = this.sourceConfig.recommendationPrefix ? [this.sourceConfig.recommendationPrefix] : [] + this.priority = 80 + + this._typeMap = this.sourceConfig.typeMap || {} + this._apiPaths = this.sourceConfig.apiPaths || {} + this._linkPaths = this.sourceConfig.linkPaths || {} + this._cdnPaths = this.sourceConfig.cdnPaths || {} + this._fields = this.sourceConfig.fields || {} + this._playBody = this.sourceConfig.playBody || {} + this._streamParamName = this.sourceConfig.streamParamName || '' + } + + _resolvePath(obj, path) { + if (!path || obj == null) return undefined + const parts = path.split('.') + let current = obj + for (const part of parts) { + if (current == null) return undefined + current = current[part] + } + return current + } + + _f(obj, fieldKey, defaultVal = null) { + const mapping = this._fields[fieldKey] + if (!mapping) return defaultVal + const paths = Array.isArray(mapping) ? mapping : [mapping] + for (const path of paths) { + const val = this._resolvePath(obj, path) + if (val !== undefined && val !== null) return val + } + return defaultVal + } + + _tpl(template, vars) { + if (!template) return '' + return template.replace(/\{(\w+)\}/g, (_, key) => vars[key] ?? '') + } + + _buildApiPath(type, vars) { + return this._tpl(this._apiPaths[type], vars) + } + + _buildLink(type, vars) { + const template = this._linkPaths[type] + if (!template) return '' + return (this.sourceConfig.linkBase || '') + this._tpl(template, vars) + } + + _buildCdn(type, vars) { + const template = this._cdnPaths[type] + if (!template) return null + const base = this.sourceConfig.cdnBase || '' + if (!base) return null + return base + this._tpl(template, vars) + } + + async setup() { + if (!this.sourceConfig.linkBase || !this.sourceConfig.privateApiBase) { + logger('warn', 'Sources', 'Rip source missing required config (linkBase, privateApiBase). Skipping.') + return false + } + + if (!this.nodelink.externalApiManager?.ripEnabled) { + logger('warn', 'Sources', 'Rip source requires externalApi.rip to be enabled for token management. Skipping.') + return false + } + + logger('info', 'Sources', 'Loaded Rip source.') + return true + } + + _getApiBase(isDev) { + return isDev ? (this.sourceConfig.devApiBase || this.sourceConfig.privateApiBase) : this.sourceConfig.privateApiBase + } + + _getLinkBase(isDev) { + return isDev ? (this.sourceConfig.devLinkBase || this.sourceConfig.linkBase) : this.sourceConfig.linkBase + } + + _getOrigin(isDev) { + const base = this._getLinkBase(isDev) + return base.endsWith('/') ? base.slice(0, -1) : base + } + + _getToken(type) { + return this.nodelink.externalApiManager.getRipToken(type) + } + + async _apiRequest(url, { authorization = false, body = null, tokenType = null } = {}) { + const headers = { + 'User-Agent': this.sourceConfig.userAgent || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36' + } + + if (authorization) { + const tokenData = this._getToken(tokenType) + if (!tokenData || !tokenData.token) { + throw new Error('Failed to get access token') + } + headers['Authorization'] = `Bearer ${tokenData.token}` + } + + const options = { + method: body ? 'POST' : 'GET', + headers + } + + if (body) { + options.body = body + options.disableBodyCompression = true + } + + const { body: responseBody, error, statusCode } = await makeRequest(url, options) + + if (error) throw error + if (statusCode < 200 || statusCode >= 300) { + throw new Error(`API request failed with status ${statusCode}`) + } + + return responseBody + } + + async _apiRequestWithFallback(path, { authorization = false, body = null } = {}) { + const privateBase = this.sourceConfig.privateApiBase + const devBase = this.sourceConfig.devApiBase + + // Try primary (private) first + try { + const json = await this._apiRequest(`${privateBase}${path}`, { + authorization, + body, + tokenType: 'main' + }) + if (json) return json + } catch (e) { + if (devBase) { + logger('debug', 'Sources', `[Rip] Primary API failed for ${path}, trying dev fallback: ${e.message}`) + } else { + throw e + } + } + + // Try dev fallback + if (devBase) { + const json = await this._apiRequest(`${devBase}${path}`, { + authorization, + body, + tokenType: 'dev' + }) + return json + } + + return null + } + + async search(query, _sourceTerm) { + const searchApiBase = this.sourceConfig.searchApiBase + if (!searchApiBase) { + return { loadType: 'empty', data: {} } + } + + try { + const searchPath = this._buildApiPath('search', { query: encodeURIComponent(query), limit: '20' }) + const url = `${searchApiBase}${searchPath}` + const json = await this._apiRequest(url, { authorization: false }) + + const tracks = this._f(json, 'searchTracks') + if (!tracks?.length) { + return { loadType: 'empty', data: {} } + } + + const parsed = this._parseTracks(tracks) + if (!parsed.length) return { loadType: 'empty', data: {} } + + return { loadType: 'search', data: parsed } + } catch (e) { + logger('error', 'Sources', `[Rip] Search failed: ${e.message}`) + return { exception: { message: 'Search failed.', severity: 'common' } } + } + } + + async resolve(queryUrl) { + if (!this.patterns.length) return null + + const match = queryUrl.match(this.patterns[0]) + if (!match?.groups) return null + + const id = match.groups.identifier + const type = match.groups.type + const internalType = this._typeMap[type] + if (!internalType) return null + + try { + switch (internalType) { + case 'album': + return await this._getAlbum(id) + case 'playlist': + return await this._getPlaylist(id) + case 'artist': + return await this._getArtist(id) + case 'track': + return await this._getTrack(id) + default: + return null + } + } catch (e) { + logger('error', 'Sources', `[Rip] Resolve failed: ${e.message}`) + return { exception: { message: e.message, severity: 'common' } } + } + } + + async _getTrack(id) { + const path = this._buildApiPath('track', { id }) + const json = await this._apiRequestWithFallback(path, { authorization: false }) + if (!json) return { loadType: 'empty', data: {} } + + const track = this._parseTrack(json) + if (!track) return { loadType: 'empty', data: {} } + + return { loadType: 'track', data: track } + } + + async _getAlbum(id) { + const path = this._buildApiPath('album', { id }) + const json = await this._apiRequestWithFallback(path, { authorization: false }) + if (!json) return { loadType: 'empty', data: {} } + + const tracks = this._parseTracks(this._f(json, 'albumTracks', [])) + const albumId = this._f(json, 'albumId') || id + + return { + loadType: 'playlist', + data: { + info: { + name: this._f(json, 'albumTitle', 'Unknown Album'), + selectedTrack: -1 + }, + pluginInfo: { + type: 'album', + url: this._buildLink('album', { id: albumId }), + artworkUrl: this._f(json, 'albumArtwork'), + author: this._f(json, 'albumAuthor', 'Unknown Artist'), + totalTracks: this._f(json, 'albumTrackCount') || tracks.length + }, + tracks + } + } + } + + async _getPlaylist(id) { + const path = this._buildApiPath('playlist', { id }) + const json = await this._apiRequestWithFallback(path, { authorization: true }) + if (!json) return { loadType: 'empty', data: {} } + + let tracks = [] + const trackCount = this._f(json, 'playlistTrackCount', 0) + if (trackCount > 0) { + const tracksPath = this._buildApiPath('playlistTracks', { id }) + const tracksJson = await this._apiRequestWithFallback(tracksPath, { authorization: true }) + const tracksData = this._f(tracksJson, 'playlistTracksData') + if (tracksData) { + tracks = this._parseTracks(tracksData) + } + } + + const playlistId = this._f(json, 'playlistId') || id + const thumbnail = this._f(json, 'playlistThumbnail') + + return { + loadType: 'playlist', + data: { + info: { + name: this._f(json, 'playlistTitle', 'Unknown Playlist'), + selectedTrack: -1 + }, + pluginInfo: { + type: 'playlist', + url: this._buildLink('playlist', { id: playlistId }), + artworkUrl: this._buildCdn('playlistArt', { id: playlistId, thumbnail }), + author: 'Unknown Author', + totalTracks: trackCount || tracks.length + }, + tracks + } + } + } + + async _getArtist(id) { + const path = this._buildApiPath('artistProfile', { id }) + const json = await this._apiRequestWithFallback(path, { authorization: true }) + if (!json) return { loadType: 'empty', data: {} } + + const artistName = this._f(json, 'artistName', 'Unknown Artist') + const artistId = this._f(json, 'artistId') || id + const tracks = this._parseTracks(this._f(json, 'artistTopTracks', [])) + + return { + loadType: 'playlist', + data: { + info: { + name: `${artistName}'s Top Tracks`, + selectedTrack: -1 + }, + pluginInfo: { + type: 'artist', + url: this._buildLink('artist', { id: artistId }), + artworkUrl: this._f(json, 'artistAvatar'), + author: artistName, + totalTracks: tracks.length + }, + tracks + } + } + } + + _parseTracks(arr) { + const tracks = [] + if (!Array.isArray(arr)) return tracks + for (const item of arr) { + const t = this._parseTrack(item) + if (t) tracks.push(t) + } + return tracks + } + + _parseTrack(json) { + if (!json) return null + if (this._f(json, 'playable') === false) return null + + const author = this._f(json, 'author', 'Unknown Artist') + const authorId = this._f(json, 'authorId', '') + const trackId = this._f(json, 'trackId', '') + const trackIdForUri = this._f(json, 'trackIdForUri', '') + + const trackInfo = { + identifier: trackId, + title: this._f(json, 'title', 'Unknown Title'), + author, + length: this._f(json, 'duration', 0), + sourceName: 'rip', + artworkUrl: this._f(json, 'artwork'), + uri: this._buildLink('track', { id: trackIdForUri }), + isStream: false, + isSeekable: true, + position: 0, + isrc: this._f(json, 'isrc') + } + + const releaseId = this._f(json, 'releaseId') + + return { + encoded: encodeTrack(trackInfo), + info: trackInfo, + pluginInfo: { + albumName: this._f(json, 'releaseTitle'), + albumUrl: releaseId ? this._buildLink('album', { id: releaseId }) : null, + artistUrl: authorId ? this._buildLink('artist', { id: authorId }) : null, + artistArtworkUrl: this._f(json, 'authorAvatar') + } + } + } + + async getTrackUrl(decodedTrack) { + const privateBase = this.sourceConfig.privateApiBase + const devBase = this.sourceConfig.devApiBase + const userAgent = this.sourceConfig.userAgent || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36' + const playPath = this._buildApiPath('play', { id: decodedTrack.identifier }) + + const tryEndpoint = async (apiBase, isDev) => { + const tokenData = this._getToken(isDev ? 'dev' : 'main') + if (!tokenData?.token) return null + + const origin = this._getOrigin(isDev) + const referer = this._getLinkBase(isDev) + + const { body, error } = await makeRequest(`${apiBase}${playPath}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${tokenData.token}`, + 'Origin': origin, + 'Referer': referer, + 'User-Agent': userAgent + }, + body: this._playBody, + disableBodyCompression: true + }) + + if (error) return null + const url = this._f(body, 'playUrl') + if (!url) return null + return { ...body, _resolvedUrl: url } + } + + // Randomly pick an endpoint to start with, fall back to other + const tryDev = Math.random() < 0.5 + let sourceData = await tryEndpoint(tryDev ? (devBase || privateBase) : privateBase, tryDev && !!devBase) + + if (!sourceData?._resolvedUrl) { + sourceData = await tryEndpoint(!tryDev ? (devBase || privateBase) : privateBase, !tryDev && !!devBase) + } + + if (!sourceData?._resolvedUrl) { + const searchResult = await this.nodelink.sources.searchWithDefault( + `${decodedTrack.title} ${decodedTrack.author}` + ) + + const bestMatch = getBestMatch(searchResult.data, decodedTrack) + if (!bestMatch) { + return { exception: { message: 'No suitable alternative found.', severity: 'fault' } } + } + + const streamInfo = await this.nodelink.sources.getTrackUrl(bestMatch.info) + return { newTrack: bestMatch, ...streamInfo } + } + + const streamUrl = sourceData._resolvedUrl + const format = this._guessFormat(streamUrl) + const isDev = tryDev && !!devBase + + const additionalData = { + isDev, + userAgent, + origin: this._getOrigin(isDev), + referer: this._getLinkBase(isDev) + } + + if (this._streamParamName) { + additionalData.streamParam = this._extractQueryParam(streamUrl, this._streamParamName) || '' + } + + return { + url: streamUrl, + protocol: 'https', + format, + additionalData + } + } + + async loadStream(decodedTrack, url, _protocol, additionalData) { + try { + const headers = { + 'User-Agent': additionalData?.userAgent || this.sourceConfig.userAgent || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36' + } + + if (additionalData?.origin) headers['Origin'] = additionalData.origin + if (additionalData?.referer) headers['Referer'] = additionalData.referer + if (this._streamParamName && additionalData?.streamParam) headers['Cookie'] = `${this._streamParamName}=${additionalData.streamParam}` + + const res = await http1makeRequest(url, { + method: 'GET', + headers, + streamOnly: true + }) + + if (res.error || !res.stream) { + throw res.error || new Error('Failed to get stream') + } + + const out = new PassThrough() + const src = res.stream + + src.pipe(out) + src.once('error', (err) => out.destroy(err)) + out.once('close', () => src.destroy()) + out.once('error', () => src.destroy()) + out.once('end', () => out.emit('finishBuffering')) + + const format = additionalData?.format || this._guessFormat(url) + const streamType = this._formatToMime(format) + + return { stream: out, type: streamType } + } catch (err) { + return { exception: { message: err.message, severity: 'common' } } + } + } + + _guessFormat(url) { + try { + const pathname = new URL(url).pathname + const lastDot = pathname.lastIndexOf('.') + if (lastDot !== -1 && lastDot < pathname.length - 1) { + const ext = pathname.substring(lastDot + 1).toLowerCase() + if (['aac', 'opus', 'ogg', 'mp3', 'mp4', 'm4a', 'flac', 'wav', 'webm'].includes(ext)) { + return ext + } + } + } catch {} + return 'aac' + } + + _formatToMime(format) { + switch (format) { + case 'aac': case 'm4a': case 'mp4': return 'audio/aac' + case 'opus': case 'ogg': return 'audio/ogg' + case 'mp3': return 'audio/mpeg' + case 'flac': return 'audio/flac' + case 'wav': return 'audio/wav' + case 'webm': return 'video/webm' + default: return 'audio/aac' + } + } + + _extractQueryParam(url, param) { + try { + return new URL(url).searchParams.get(param) || '' + } catch { + return '' + } + } +} diff --git a/src/sources/youtube/OAuth.js b/src/sources/youtube/OAuth.js index 43925744..9f6bfb13 100644 --- a/src/sources/youtube/OAuth.js +++ b/src/sources/youtube/OAuth.js @@ -30,9 +30,19 @@ export default class OAuth { this.currentTokenIndex = 0 this.accessToken = null this.tokenExpiry = 0 + this._lastExternalToken = null } async getAccessToken() { + // External API tokens take precedence + if (this.nodelink.externalApiManager?.youtubeEnabled) { + const externalToken = this.nodelink.externalApiManager.getYoutubeToken() + if (externalToken) { + this._lastExternalToken = externalToken + return externalToken + } + } + if ( !this.refreshToken.length || (this.refreshToken.length === 1 && this.refreshToken[0] === '') @@ -160,6 +170,13 @@ export default class OAuth { } } + async reportTokenFailure() { + if (this._lastExternalToken && this.nodelink.externalApiManager?.youtubeEnabled) { + await this.nodelink.externalApiManager.reportYoutubeTokenFailure(this._lastExternalToken) + this._lastExternalToken = null + } + } + static async acquireRefreshToken() { const data = { client_id: CLIENT_ID,