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..808b1d6d 100644 --- a/config.default.js +++ b/config.default.js @@ -509,6 +509,21 @@ 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 + } + }, 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..9392dccc 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -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..7278fd3d --- /dev/null +++ b/src/managers/externalApiManager.js @@ -0,0 +1,375 @@ +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() + + // 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 + } + + _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() + } + } + + // ---- 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) + } + + // ---- Cleanup ---- + + stop() { + if (this._deezerRefreshTimer) { + clearInterval(this._deezerRefreshTimer) + this._deezerRefreshTimer = null + } + if (this._youtubeRefreshTimer) { + clearInterval(this._youtubeRefreshTimer) + this._youtubeRefreshTimer = 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/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,