From f88d7146ae1fb7574e56e3f2b66cef12c83019ab Mon Sep 17 00:00:00 2001 From: D3SOX Date: Sun, 9 Aug 2026 11:53:05 +0200 Subject: [PATCH 1/3] feat: support PumpYourSound fangates over Bandcamp store links Prefer traditional gates in the track description (e.g. PumpYourSound) when purchase_url points at Bandcamp, and unlock PYS via SoundCloud OAuth plus the fangate downloadLink redirect. --- README.md | 6 +- src/index.ts | 23 +- src/pumpyoursound.ts | 589 ++++++++++++++++++++++++++++++++++ src/server.ts | 24 ++ src/soundcloud.ts | 1 + src/utils.test.ts | 40 +++ src/utils.ts | 24 +- userscript/sc-gate-dl.user.js | 9 + webui/src/components/App.tsx | 7 +- 9 files changed, 711 insertions(+), 12 deletions(-) create mode 100644 src/pumpyoursound.ts diff --git a/README.md b/README.md index 1332432..97dcedc 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # sc-gate-dl -Download and tag SoundCloud tracks unlocked via Hypeddit, Droploud, GateRush, DownloadGater, StillHype, Bandcamp, or a direct download link. +Download and tag SoundCloud tracks unlocked via Hypeddit, Droploud, GateRush, DownloadGater, StillHype, PumpYourSound, Bandcamp, or a direct download link. ## Features -- 🎡 Automatically download audio from Hypeddit, Droploud, GateRush, DownloadGater, StillHype, Bandcamp, and direct file links (e.g. Dropbox `dl=1`) +- 🎡 Automatically download audio from Hypeddit, Droploud, GateRush, DownloadGater, StillHype, PumpYourSound, Bandcamp, and direct file links (e.g. Dropbox `dl=1`) - ⚑ Browserless fast path that skips the browser for gates that don't need real verification (see [How It Works](#how-it-works)) - πŸ”„ Handles multiple gate types (see [How It Works](#how-it-works)) - πŸ“ Fetches metadata from the provided SoundCloud link @@ -199,7 +199,7 @@ Panel size/position is remembered in `localStorage` (`sc-gate-dl-panel-geom`). - Spotify gate: Authorizes Spotify access - Download gate: Triggers the audio download -Droploud, GateRush, DownloadGater, and StillHype gates are handled via their own downloaders with similar email / social unlock flows. StillHype requires a real SoundCloud OAuth connection (follow / like / repost / comment are verified server-side). +Droploud, GateRush, DownloadGater, StillHype, and PumpYourSound gates are handled via their own downloaders with similar email / social unlock flows. StillHype and PumpYourSound require a real SoundCloud OAuth connection (follow / like / repost / comment are verified server-side). Traditional gate URLs in the track description are preferred over Bandcamp/SoundCloud store links in `purchase_url`. **Bandcamp / yt-dlp**: When a SoundCloud track’s purchase URL (or description) points at Bandcamp instead of a gate, the file is downloaded with [yt-dlp](https://github.com/yt-dlp/yt-dlp) (browserless). For Bandcamp album links, the matching track is selected from the SoundCloud title; if auto-match fails, the CLI and Web UI let you pick a track from the album. Traditional unlock gates still take priority if both are present. If no gate or Bandcamp URL is found, the CLI and Web UI can fall back to downloading the SoundCloud track itself via yt-dlp. diff --git a/src/index.ts b/src/index.ts index 549a130..e05084a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { DroploudDownloader } from './droploud'; import { GaterushDownloader } from './gaterush'; import { HypedditDownloader } from './hypeddit'; import { HypedditHttpDownloader } from './hypedditHttp'; +import { PumpyoursoundDownloader } from './pumpyoursound'; import { SoundcloudClient } from './soundcloud'; import { StillhypeDownloader } from './stillhype'; import { @@ -79,11 +80,11 @@ try { } else { gateUrl = await input({ message: - 'Enter a Hypeddit, Droploud, GateRush, DownloadGater, Bandcamp, direct download, or smart-link URL', + 'Enter a Hypeddit, Droploud, GateRush, DownloadGater, StillHype, PumpYourSound, Bandcamp, direct download, or smart-link URL', validate: async (value) => { const resolved = await resolveGateUrlOrFollow(value); if (!resolved || resolved.provider === 'soundcloud') { - return 'A valid Hypeddit, Droploud, GateRush, DownloadGater, Bandcamp, direct download, or resolvable gate URL is required'; + return 'A valid Hypeddit, Droploud, GateRush, DownloadGater, StillHype, PumpYourSound, Bandcamp, direct download, or resolvable gate URL is required'; } return true; }, @@ -105,7 +106,7 @@ try { if (!gateUrl || !gate) { throw new Error( - 'A valid Hypeddit, Droploud, GateRush, DownloadGater, Bandcamp, direct download, or SoundCloud URL is required', + 'A valid Hypeddit, Droploud, GateRush, DownloadGater, StillHype, PumpYourSound, Bandcamp, direct download, or SoundCloud URL is required', ); } @@ -246,6 +247,22 @@ try { } finally { await stillhypeDownloader.close(); } + } else if (gate.provider === 'pumpyoursound') { + usedBrowser = true; + const pumpyoursoundDownloader = new PumpyoursoundDownloader(gateConfig); + try { + await pumpyoursoundDownloader.initialize(); + if (initializeLogins) { + await pumpyoursoundDownloader.prepareLogins(); + if (config) { + await saveConfig({ ...config, initializeLogins: false }); + console.log('βœ“ Updated config.json: initializeLogins set to false'); + } + } + downloadFilename = await pumpyoursoundDownloader.downloadAudio(gateUrl); + } finally { + await pumpyoursoundDownloader.close(); + } } else { // Hypeddit: always try plain HTTP first (email + social skip gates). const httpDownloader = new HypedditHttpDownloader(gateConfig); diff --git a/src/pumpyoursound.ts b/src/pumpyoursound.ts new file mode 100644 index 0000000..462afe4 --- /dev/null +++ b/src/pumpyoursound.ts @@ -0,0 +1,589 @@ +import type { Browser, Page } from 'puppeteer'; +import { browserModeToLaunchOptions, launchAppBrowser } from './browserLaunch'; +import { DirectDownloader } from './directDownload'; +import type { ProgressCallback } from './hypeddit'; +import Selectors from './selectors'; +import type { HypedditConfig } from './types'; +import { loadCookies, timeout, trimExtractedUrl } from './utils'; + +const SC_AUTHORIZE_RE = /secure\.soundcloud\.com\/authorize/i; + +/** + * PumpYourSound fangate downloader. + * + * Flow (from live repro): + * 1. Complete SC Follow via SC.connect popup β†’ Allow β†’ + * `/?do=mySoundcloud*` β†’ `/auth/soundcloud-events/` + * 2. Mark Instagram/YouTube/Facebook via `/auth/...` AJAX (popup can be closed) + * 3. Optional SC comment via `/auth/sc-comment/` + * 4. POST free-download form β†’ 303 `/fangate/download/{id}?downloadLink=...` + * 5. Fetch `downloadLink` / `data-redirect-link` via DirectDownloader + */ +export class PumpyoursoundDownloader { + private browser!: Browser; + private downloadFilename: string | null = null; + private config: HypedditConfig; + private progressCallback: ProgressCallback | null = null; + + constructor(config: HypedditConfig) { + this.config = config; + } + + setProgressCallback(callback: ProgressCallback): void { + this.progressCallback = callback; + } + + private emitProgress( + stage: Parameters[0], + message: string, + percent: number, + extra?: Parameters[3], + ): void { + this.progressCallback?.(stage, message, percent, extra); + } + + async initialize() { + this.browser = await launchAppBrowser({ + ...browserModeToLaunchOptions(this.config.browserMode), + userDataDir: this.config.userDataDir ?? './browser-data', + // SC.connect authorize popup shares session cookies more reliably with this. + args: ['--fingerprint-allow-3p-cookies'], + }); + + const browserContext = this.browser.defaultBrowserContext(); + const soundCloudCookies = await loadCookies('soundcloud-cookies.json'); + await browserContext.setCookie(...soundCloudCookies); + } + + async prepareLogins() { + const soundCloudPage = await this.browser.newPage(); + await soundCloudPage.setViewport({ width: 1920, height: 1080 }); + await soundCloudPage.goto('https://soundcloud.com/messages'); + + try { + await soundCloudPage.waitForSelector( + Selectors.SOUNDCLOUD_CAPTCHA_CONTAINER, + { timeout: 5_000 }, + ); + console.log( + 'PumpYourSound: SoundCloud captcha present during login warm-up; solve it in the browser window if needed.', + ); + await soundCloudPage.waitForSelector( + Selectors.SOUNDCLOUD_CAPTCHA_CONTAINER, + { hidden: true, timeout: 120_000 }, + ); + } catch { + // no captcha + } + + await soundCloudPage.waitForSelector(Selectors.SOUNDCLOUD_LIBRARY_LINK, { + timeout: 30_000, + }); + await Promise.all([ + soundCloudPage.click(Selectors.SOUNDCLOUD_LIBRARY_LINK), + soundCloudPage.waitForNavigation({ waitUntil: 'domcontentloaded' }), + ]); + await soundCloudPage.waitForFunction(() => + window.location.href.includes('/you/library'), + ); + await soundCloudPage.close(); + } + + async downloadAudio(url: string): Promise { + console.log('Navigating to PumpYourSound gate...'); + this.emitProgress( + 'handling_gates', + 'Navigating to PumpYourSound gate...', + 25, + ); + + const page = await this.browser.newPage(); + try { + await page.setViewport({ width: 1920, height: 1080 }); + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 }); + try { + await page.waitForNetworkIdle({ timeout: 15_000, idleTime: 10 }); + } catch { + // continue + } + + await this.dismissOverlays(page); + await this.completeGateSteps(page); + + const fileUrl = await this.submitAndReadDownloadLink(page); + console.log('PumpYourSound download link:', fileUrl); + this.emitProgress( + 'handling_gates', + 'Downloading PumpYourSound file...', + 80, + ); + + const direct = new DirectDownloader(); + if (this.progressCallback) { + direct.setProgressCallback(this.progressCallback); + } + this.downloadFilename = await direct.downloadAudio(fileUrl); + return this.downloadFilename; + } finally { + await page.close().catch(() => {}); + } + } + + async close() { + await this.browser?.close(); + } + + private async dismissOverlays(page: Page) { + await page.evaluate(() => { + document + .querySelector( + '#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll, #CybotCookiebotDialogBodyButtonAccept', + ) + ?.click(); + for (const btn of document.querySelectorAll('button')) { + if (/^no$/i.test((btn.textContent || '').trim())) { + btn.click(); + } + } + }); + await timeout(400); + } + + private async completeGateSteps(page: Page) { + const deadline = Date.now() + 240_000; + let loops = 0; + while (Date.now() < deadline && loops++ < 14) { + const status = await this.readStepStatus(page); + console.log('PumpYourSound steps:', status); + + if (status.length > 0 && status.every((s) => s.done)) { + return; + } + + const needSc = status.some((s) => s.kind === 'soundcloud' && !s.done); + const needComment = status.some((s) => s.kind === 'comment' && !s.done); + const needSocial = status.some( + (s) => + (s.kind === 'instagram' || + s.kind === 'facebook' || + s.kind === 'youtube' || + s.kind === 'twitter') && + !s.done, + ); + + if (needSc) { + this.emitProgress( + 'handling_gates', + 'Connecting SoundCloud on PumpYourSound...', + 40, + { currentGate: 'sc' }, + ); + await this.handleSoundcloudFollow(page); + continue; + } + + if (needSocial) { + this.emitProgress( + 'handling_gates', + 'Handling PumpYourSound social step...', + 55, + { currentGate: 'social' }, + ); + const clicked = await this.handleSocialMarkComplete(page); + if (!clicked) { + throw new Error( + 'PumpYourSound social step is incomplete but no mark-complete control was found.', + ); + } + await timeout(1_200); + continue; + } + + if (needComment) { + this.emitProgress( + 'handling_gates', + 'Sending SoundCloud comment on PumpYourSound...', + 65, + { currentGate: 'sc' }, + ); + await this.handleComment(page); + continue; + } + + // Email-only / unknown remaining UI β€” try continuing to download. + if (status.length === 0) { + return; + } + throw new Error( + `Unsupported PumpYourSound gate steps: ${status + .filter((s) => !s.done) + .map((s) => s.kind) + .join(', ')}`, + ); + } + + const finalStatus = await this.readStepStatus(page); + if (!(finalStatus.length > 0 && finalStatus.every((s) => s.done))) { + throw new Error( + 'PumpYourSound gate steps did not complete in time. Check SoundCloud login / Allow OAuth.', + ); + } + } + + private async readStepStatus( + page: Page, + ): Promise<{ kind: string; done: boolean }[]> { + return page.evaluate(() => { + return Array.from(document.querySelectorAll('.fangateStep')).map((el) => { + const cls = el.className; + const kind = + cls.match(/fangateStep--([a-z]+)/i)?.[1]?.toLowerCase() || 'unknown'; + return { kind, done: el.classList.contains('done') }; + }); + }); + } + + private async handleSoundcloudFollow(page: Page) { + const clicked = await page.evaluate(() => { + const el = document.querySelector( + '.socBtn__soundcloud.sn-connect-with-soundcloud', + ); + if (!el) return false; + el.click(); + return true; + }); + if (!clicked) { + throw new Error('PumpYourSound SoundCloud Follow button not found.'); + } + await this.completeSoundcloudOauth(page, 'soundcloud'); + } + + private async handleComment(page: Page) { + const comment = this.config.comment.trim(); + if (comment.length < 2) { + throw new Error( + 'This PumpYourSound gate requires SC_COMMENT (at least 2 characters).', + ); + } + + await page.evaluate((text) => { + const input = document.querySelector( + 'input[name="fangate_comment"], input.fangatex__icomment', + ); + if (input) { + input.value = text; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + } + const send = document.querySelector('#fangate-send-comment'); + const href = send?.getAttribute('data-href'); + if (send && href) { + const u = new URL(href, location.origin); + u.searchParams.set('text', text); + send.setAttribute('data-href', `${u.pathname}${u.search}`); + } + if (!send) { + throw new Error('PumpYourSound comment send button not found.'); + } + send.click(); + }, comment); + + await this.completeSoundcloudOauth(page, 'comment'); + } + + /** + * Wait for authorize + #submit_approval, click Allow, then wait until the + * matching fangate step is marked done. Never force-closes the Allow tab. + */ + private async completeSoundcloudOauth( + gatePage: Page, + expect: 'soundcloud' | 'comment', + ) { + const deadline = Date.now() + 180_000; + let lastLog = 0; + let lastAllowAt = 0; + + while (Date.now() < deadline) { + if (gatePage.isClosed()) { + throw new Error( + 'PumpYourSound gate tab closed during SoundCloud OAuth.', + ); + } + + const done = await gatePage + .evaluate((kind) => { + const sel = + kind === 'comment' + ? '.fangateStep--comment.done' + : '.fangateStep--soundcloud.done'; + return !!document.querySelector(sel); + }, expect) + .catch(() => false); + if (done) { + console.log(`PumpYourSound: ${expect} step marked done`); + return; + } + + const authorizePage = await this.findAuthorizePageWithAllow(); + if (authorizePage) { + const canRetry = Date.now() - lastAllowAt > 4_000; + if (lastAllowAt === 0 || canRetry) { + const method = await this.clickSoundcloudAllow(authorizePage); + console.log( + `PumpYourSound: Allow β†’ ${method} (${authorizePage.url().slice(0, 100)})`, + ); + if (method !== 'miss') { + lastAllowAt = Date.now(); + await timeout(2_000); + continue; + } + } + } + + for (const candidate of await this.browser.pages(true)) { + if (candidate.isClosed() || candidate === gatePage) continue; + if (!SC_AUTHORIZE_RE.test(candidate.url())) continue; + const needsLogin = await candidate + .evaluate(async () => { + const loginRe = /sign in or create an account/i; + if (!loginRe.test(document.body?.innerText || '')) return false; + await new Promise((r) => setTimeout(r, 1_500)); + const text = document.body?.innerText || ''; + const hasAllow = !!document.querySelector('#submit_approval'); + return loginRe.test(text) && !hasAllow; + }) + .catch(() => false); + if (needsLogin) { + throw new Error( + 'SoundCloud is not logged in for PumpYourSound OAuth. Run Initialize Logins (or CLI initializeLogins) first.', + ); + } + } + + if (Date.now() - lastLog > 5_000) { + const urls = (await this.browser.pages(true)).map((p) => + p.isClosed() ? '(closed)' : p.url().slice(0, 100), + ); + console.log('PumpYourSound OAuth waiting…', urls.join(' | ')); + lastLog = Date.now(); + } + + await timeout(400); + } + + throw new Error( + `Timed out waiting for PumpYourSound ${expect} step after SoundCloud OAuth.`, + ); + } + + private async findAuthorizePageWithAllow(): Promise { + const pages = (await this.browser.pages(true)).filter((p) => !p.isClosed()); + for (const candidate of [...pages].reverse()) { + if (!SC_AUTHORIZE_RE.test(candidate.url())) continue; + try { + for (const frame of candidate.frames()) { + const handle = await frame.$(Selectors.SC_SUBMIT_APPROVAL_BUTTON); + if (handle) { + await handle.dispose().catch(() => {}); + return candidate; + } + } + } catch { + // navigated + } + } + return null; + } + + private async clickSoundcloudAllow(oauthPage: Page): Promise { + await oauthPage.bringToFront().catch(() => {}); + for (const frame of oauthPage.frames()) { + try { + const submit = await frame.$(Selectors.SC_SUBMIT_APPROVAL_BUTTON); + if (submit) { + await submit.click({ delay: 40 }); + return 'submit_approval'; + } + } catch { + // frame gone + } + } + for (const frame of oauthPage.frames()) { + try { + const clicked = await frame.evaluate(() => { + const btn = Array.from(document.querySelectorAll('button')).find( + (b) => + /^(allow|accept|connect)$/i.test((b.textContent || '').trim()), + ) as HTMLButtonElement | undefined; + if (!btn) return null; + btn.click(); + return (btn.textContent || '').trim(); + }); + if (clicked) return clicked; + } catch { + // ignore + } + } + return 'miss'; + } + + private async handleSocialMarkComplete(page: Page): Promise { + const href = await page.evaluate(() => { + const candidates: { sel: string; step: string }[] = [ + { sel: '#instagram_follow a[data-href]', step: 'instagram' }, + { sel: '.instagram_follow_box[data-href]', step: 'instagram' }, + { sel: '#facebook_follow a[data-href]', step: 'facebook' }, + { sel: 'a[href*="youtube-follow"]', step: 'youtube' }, + { + sel: 'a[href*="markFacebookStepAsComplete"]', + step: 'facebook', + }, + ]; + for (const { sel, step } of candidates) { + const el = document.querySelector(sel); + if (!el) continue; + const stepEl = + document.querySelector(`.fangateStep--${step}:not(.done)`) || + el.closest('li')?.querySelector('.fangateStep:not(.done)'); + if (!stepEl && step !== 'facebook') { + // Skip always-present modal links when that step isn't on the fangate. + if ( + step === 'youtube' && + !document.querySelector('.fangateStep--youtube:not(.done)') + ) { + continue; + } + } + if ( + document.querySelector(`.fangateStep--${step}.done`) && + step !== 'facebook' + ) { + continue; + } + if (stepEl?.classList.contains('done')) continue; + el.click(); + return el.getAttribute('data-href') || el.getAttribute('href'); + } + return null; + }); + if (!href) return false; + console.log('PumpYourSound social mark-complete:', href); + await timeout(2_000); + for (const p of await this.browser.pages(true)) { + if (p === page || p.isClosed()) continue; + if (/instagram\.com|youtube\.com|facebook\.com/i.test(p.url())) { + await p.close().catch(() => {}); + } + } + return true; + } + + private async submitAndReadDownloadLink(page: Page): Promise { + this.emitProgress( + 'handling_gates', + 'Submitting PumpYourSound download...', + 75, + ); + + const emailRequired = await page.$('#frm-freeDownloadForm-email'); + if (emailRequired) { + const email = this.config.email?.trim(); + if (!email) { + throw new Error( + 'This PumpYourSound gate requires an email. Set HYPEDDIT_EMAIL in your .env file.', + ); + } + await page.evaluate((value) => { + const modalToggle = document.querySelector( + 'a[href="#confirm-email-subscription"]', + ); + modalToggle?.click(); + const input = document.querySelector( + '#frm-freeDownloadForm-email', + ); + if (input) { + input.value = value; + input.dispatchEvent(new Event('input', { bubbles: true })); + } + }, email); + await timeout(400); + } + + const navigation = page + .waitForNavigation({ + waitUntil: 'domcontentloaded', + timeout: 60_000, + }) + .catch(() => null); + + try { + await page.evaluate(() => { + const submit = document.querySelector( + '#frm-freeDownloadForm input[type="submit"][name="send"]', + ); + if (submit) { + submit.click(); + return; + } + document + .querySelector('#frm-freeDownloadForm') + ?.requestSubmit(); + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if ( + !/Execution context was destroyed|Cannot find context|frame was detached|Navigating frame/i.test( + message, + ) + ) { + throw error; + } + // Form submit navigated away β€” expected. + } + + await navigation; + + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + try { + const fromUrl = new URL(page.url()).searchParams.get('downloadLink'); + if (fromUrl) return trimExtractedUrl(fromUrl); + } catch { + // ignore + } + const link = await this.readDownloadLink(page).catch(() => null); + if (link) return link; + await timeout(400); + } + + throw new Error( + 'PumpYourSound download page never exposed a downloadLink after form submit. Incomplete gate steps?', + ); + } + + private async readDownloadLink(page: Page): Promise { + if (page.isClosed()) return null; + try { + const fromUrl = new URL(page.url()).searchParams.get('downloadLink'); + if (fromUrl) return trimExtractedUrl(fromUrl); + } catch { + // ignore + } + return page + .evaluate(() => { + const fromData = document + .querySelector('.fangateDownload') + ?.getAttribute('data-redirect-link'); + if (fromData) return fromData; + try { + const fromQuery = new URL(location.href).searchParams.get( + 'downloadLink', + ); + return fromQuery; + } catch { + return null; + } + }) + .then((value) => (value ? trimExtractedUrl(value) : null)); + } +} diff --git a/src/server.ts b/src/server.ts index 0a8c920..5ab0de2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -13,6 +13,7 @@ import { HypedditDownloader } from './hypeddit'; import { HypedditHttpDownloader } from './hypedditHttp'; import { JobQueue } from './jobQueue'; import { jobStore } from './jobStore'; +import { PumpyoursoundDownloader } from './pumpyoursound'; import { SoundcloudClient } from './soundcloud'; import { isSoundcloudDownloadEnabled } from './soundcloudDownload'; import { StillhypeDownloader } from './stillhype'; @@ -433,6 +434,29 @@ async function runDownloadProcess(jobId: string): Promise { downloadFilename = await stillhypeDownloader.downloadAudio(downloadSourceUrl); throwIfCancelled(); + } else if (provider === 'pumpyoursound') { + jobStore.updateProgress( + jobId, + 'initializing_browser', + 'Launching browser for PumpYourSound...', + 10, + ); + const pumpyoursoundDownloader = new PumpyoursoundDownloader( + await prepareBrowserConfig(), + ); + activeDownloaders.set(jobId, pumpyoursoundDownloader); + pumpyoursoundDownloader.setProgressCallback(emitProgress); + await pumpyoursoundDownloader.initialize(); + throwIfCancelled(); + jobStore.updateProgress( + jobId, + 'handling_gates', + 'Processing PumpYourSound gates...', + 25, + ); + downloadFilename = + await pumpyoursoundDownloader.downloadAudio(downloadSourceUrl); + throwIfCancelled(); } else if (provider === 'direct') { jobStore.updateProgress( jobId, diff --git a/src/soundcloud.ts b/src/soundcloud.ts index 5e310b5..671413a 100644 --- a/src/soundcloud.ts +++ b/src/soundcloud.ts @@ -18,6 +18,7 @@ const GATE_PROVIDER_LABELS: Record = { gaterush: 'GateRush', downloadgater: 'DownloadGater', stillhype: 'StillHype', + pumpyoursound: 'PumpYourSound', direct: 'direct download', bandcamp: 'Bandcamp', soundcloud: 'SoundCloud', diff --git a/src/utils.test.ts b/src/utils.test.ts index 69d0fef..de3e719 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { artistTitleFilename, cookiesToNetscape, + extractGateUrl, findBandcampHomepageInHtml, findKnownGateInHtml, previewProcessedFilename, @@ -127,6 +128,28 @@ describe('resolveGateProviderUrl', () => { }); }); + test('matches PumpYourSound fangate URLs', () => { + expect( + resolveGateProviderUrl( + 'FREE DOWNLOAD: https://pumpyoursound.com/f/pys/aguanile-remix/224680', + ), + ).toEqual({ + url: 'https://pumpyoursound.com/f/pys/aguanile-remix/224680', + provider: 'pumpyoursound', + }); + }); + + test('prefers PumpYourSound over Bandcamp in the same string', () => { + expect( + resolveGateProviderUrl( + 'https://artist.bandcamp.com/track/song and https://pumpyoursound.com/f/larrylars/aguanile-remix/224680', + ), + ).toEqual({ + url: 'https://pumpyoursound.com/f/larrylars/aguanile-remix/224680', + provider: 'pumpyoursound', + }); + }); + test('matches raw audio file URLs as direct downloads', () => { expect( resolveGateProviderUrl( @@ -139,6 +162,23 @@ describe('resolveGateProviderUrl', () => { }); }); +describe('extractGateUrl', () => { + test('prefers PumpYourSound in description over Bandcamp purchase_url', () => { + expect( + extractGateUrl({ + purchase_url: + 'https://larrylars.bandcamp.com/track/aguanile-groove-hector-lavoe-larrylars-remix', + description: + 'FREE DOWNLOAD: https://pumpyoursound.com/f/pys/aguanile-remix/224680\n\nBandcamp: larrylars.bandcamp.com/', + } as Parameters[0]), + ).toEqual({ + url: 'https://pumpyoursound.com/f/pys/aguanile-remix/224680', + provider: 'pumpyoursound', + type: 'description', + }); + }); +}); + describe('findKnownGateInHtml', () => { test('follows a smart-link Bandcamp homepage to its newest album', () => { const smartLinkHtml = ` diff --git a/src/utils.ts b/src/utils.ts index d637ce4..47280d1 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -225,6 +225,7 @@ export type GateProvider = | 'gaterush' | 'downloadgater' | 'stillhype' + | 'pumpyoursound' /** Direct HTTP(S) file URL (Dropbox, Drive, raw audio link, …). */ | 'direct' | 'bandcamp' @@ -244,6 +245,9 @@ const DOWNLOADGATER_URL_RE = /https?:\/\/(?:www\.)?downloadgater\.com\/g\/[A-Za-z0-9_-]+/i; const STILLHYPE_URL_RE = /https?:\/\/(?:www\.)?stillhype\.io\/g\/[A-Za-z0-9_-]+/i; +/** pumpyoursound.com/f/{channel}/{slug}/{id} (channel may be `pys` or the artist). */ +const PUMPYOURSOUND_URL_RE = + /https?:\/\/(?:www\.)?pumpyoursound\.com\/f\/[A-Za-z0-9_-]+\/[A-Za-z0-9_-]+\/\d+/i; /** artist.bandcamp.com/track|album/... (and bare bandcamp.com). */ const BANDCAMP_URL_RE = /https?:\/\/(?:[\w-]+\.)?bandcamp\.com\/(?:track|album)\/[^\s?#]+/i; @@ -282,6 +286,10 @@ export function isStillhypeUrl(value: string): boolean { return STILLHYPE_URL_RE.test(value); } +export function isPumpyoursoundUrl(value: string): boolean { + return PUMPYOURSOUND_URL_RE.test(value); +} + export function isBandcampUrl(value: string): boolean { return BANDCAMP_URL_RE.test(value); } @@ -336,7 +344,7 @@ export function validateGateUrl(value: string): true | string { if (/^https?:\/\/\S+/i.test(value.trim())) { return true; } - return 'A valid Hypeddit, Droploud, GateRush, DownloadGater, StillHype, Bandcamp, direct download, SoundCloud, or resolvable http(s) URL is required'; + return 'A valid Hypeddit, Droploud, GateRush, DownloadGater, StillHype, PumpYourSound, Bandcamp, direct download, SoundCloud, or resolvable http(s) URL is required'; } function normalizeGateUrl( @@ -347,6 +355,7 @@ function normalizeGateUrl( provider === 'gaterush' || provider === 'downloadgater' || provider === 'stillhype' || + provider === 'pumpyoursound' || provider === 'bandcamp' || provider === 'direct' ) { @@ -396,6 +405,13 @@ function matchTraditionalGateUrl( if (stillhypeMatch) { return normalizeGateUrl(trimExtractedUrl(stillhypeMatch), 'stillhype'); } + const pumpyoursoundMatch = value.match(PUMPYOURSOUND_URL_RE)?.[0]; + if (pumpyoursoundMatch) { + return normalizeGateUrl( + trimExtractedUrl(pumpyoursoundMatch), + 'pumpyoursound', + ); + } return null; } @@ -570,6 +586,7 @@ export function findKnownGateInHtml( 'gaterush', 'downloadgater', 'stillhype', + 'pumpyoursound', ].includes(match.provider), ); if (traditional) return traditional; @@ -688,8 +705,9 @@ function collectUnresolvedHttpCandidates( } /** - * Prefer Hypeddit / Droploud / GateRush / DownloadGater from purchase_url or - * description, then Bandcamp, then direct file links (Dropbox, Drive, …). + * Prefer traditional unlock gates (Hypeddit, Droploud, GateRush, DownloadGater, + * StillHype, PumpYourSound) from purchase_url or description over Bandcamp / + * SoundCloud store links, then direct file links (Dropbox, Drive, …). */ export function extractGateUrl(track: SoundcloudTrack): GateUrlMatch | null { const { purchase_url, description } = track; diff --git a/userscript/sc-gate-dl.user.js b/userscript/sc-gate-dl.user.js index 1473b5a..e1632d0 100644 --- a/userscript/sc-gate-dl.user.js +++ b/userscript/sc-gate-dl.user.js @@ -126,6 +126,9 @@ stillhype: { icon: 'https://www.stillhype.io/favicon-32x32.png', }, + pumpyoursound: { + icon: 'https://pumpyoursound.com/favicon.ico', + }, bandcamp: { icon: 'https://s4.bcbits.com/client-bundle/1/PageLayout_1/favicon-78ff127104384a042453aca8d73be7dc.static/favicon/favicon-32x32.png', }, @@ -300,6 +303,12 @@ if (host === 'stillhype.io' && /^\/g\/[A-Za-z0-9_-]+\/?$/.test(path)) { return 'stillhype'; } + if ( + host === 'pumpyoursound.com' && + /^\/f\/[A-Za-z0-9_-]+\/[A-Za-z0-9_-]+\/\d+\/?$/i.test(path) + ) { + return 'pumpyoursound'; + } if ( (host === 'bandcamp.com' || host.endsWith('.bandcamp.com')) && (path === '/' || /^\/(?:track|album)\/[^/]+\/?$/i.test(path)) diff --git a/webui/src/components/App.tsx b/webui/src/components/App.tsx index 647a51e..655060a 100644 --- a/webui/src/components/App.tsx +++ b/webui/src/components/App.tsx @@ -1028,7 +1028,8 @@ export default function App() {

Download & tag SoundCloud tracks from Hypeddit, Droploud, GateRush, - DownloadGater, StillHype, Bandcamp, or a direct download link + DownloadGater, StillHype, PumpYourSound, Bandcamp, or a direct + download link

)} @@ -1226,7 +1227,7 @@ export default function App() {

{skipAutomaticHypedditFetch ? 'Automatic gate lookup is disabled. Enter a gate URL manually, or download the SoundCloud track via yt-dlp.' - : 'Gate URL not found in track. Enter a Hypeddit, Droploud, GateRush, DownloadGater, StillHype, Bandcamp, or direct download URL, or download via yt-dlp from SoundCloud.'} + : 'Gate URL not found in track. Enter a Hypeddit, Droploud, GateRush, DownloadGater, StillHype, PumpYourSound, Bandcamp, or direct download URL, or download via yt-dlp from SoundCloud.'}

@@ -1238,7 +1239,7 @@ export default function App() { name="hypeddit-url" value={hypedditUrlInput} onChange={(e) => setHypedditUrlInput(e.target.value)} - placeholder="https://hypeddit.com/... / droploud.com/gate/... / gaterush.me/... / downloadgater.com/g/... / stillhype.io/g/... / artist.bandcamp.com/track/... / https://www.dropbox.com/...?dl=1" + placeholder="https://hypeddit.com/... / droploud.com/gate/... / gaterush.me/... / downloadgater.com/g/... / stillhype.io/g/... / pumpyoursound.com/f/... / artist.bandcamp.com/track/... / https://www.dropbox.com/...?dl=1" autoComplete="off" required disabled={isLoading} From ffb2fec6347398a8a07805f7c5d00b2f300e9799 Mon Sep 17 00:00:00 2001 From: D3SOX Date: Sun, 9 Aug 2026 11:56:58 +0200 Subject: [PATCH 2/3] fix: show SoundCloud cleanup toast for PumpYourSound downloads DirectDownloader marks progress as browserless, which hid the Web UI cleanup prompt even though the gate already used SoundCloud OAuth. --- src/pumpyoursound.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/pumpyoursound.ts b/src/pumpyoursound.ts index 462afe4..26c34c5 100644 --- a/src/pumpyoursound.ts +++ b/src/pumpyoursound.ts @@ -120,7 +120,15 @@ export class PumpyoursoundDownloader { const direct = new DirectDownloader(); if (this.progressCallback) { - direct.setProgressCallback(this.progressCallback); + // File bytes come from HTTP, but this job already used the browser and + // SoundCloud OAuth β€” keep browserless false so the Web UI still offers + // the SoundCloud cleanup prompt when the download starts. + direct.setProgressCallback((stage, message, percent, extra) => { + this.progressCallback?.(stage, message, percent, { + ...extra, + browserless: false, + }); + }); } this.downloadFilename = await direct.downloadAudio(fileUrl); return this.downloadFilename; From d8fd4056c71a3c620b1857794842eaa876661272 Mon Sep 17 00:00:00 2001 From: D3SOX Date: Sun, 9 Aug 2026 12:04:29 +0200 Subject: [PATCH 3/3] fix: fail fast on missing PYS comment input; drop unfinished Twitter social Avoid clicking send without a comment field, and surface Twitter fangate steps as unsupported until mark-complete selectors exist. --- src/pumpyoursound.ts | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/pumpyoursound.ts b/src/pumpyoursound.ts index 26c34c5..2d63934 100644 --- a/src/pumpyoursound.ts +++ b/src/pumpyoursound.ts @@ -170,12 +170,13 @@ export class PumpyoursoundDownloader { const needSc = status.some((s) => s.kind === 'soundcloud' && !s.done); const needComment = status.some((s) => s.kind === 'comment' && !s.done); + // Twitter is listed on some fangates but has no mark-complete control yet β€” + // leave it out so it surfaces as unsupported instead of a false social miss. const needSocial = status.some( (s) => (s.kind === 'instagram' || s.kind === 'facebook' || - s.kind === 'youtube' || - s.kind === 'twitter') && + s.kind === 'youtube') && !s.done, ); @@ -278,21 +279,22 @@ export class PumpyoursoundDownloader { const input = document.querySelector( 'input[name="fangate_comment"], input.fangatex__icomment', ); - if (input) { - input.value = text; - input.dispatchEvent(new Event('input', { bubbles: true })); - input.dispatchEvent(new Event('change', { bubbles: true })); + if (!input) { + throw new Error('PumpYourSound comment input not found.'); } + input.value = text; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); const send = document.querySelector('#fangate-send-comment'); - const href = send?.getAttribute('data-href'); - if (send && href) { + if (!send) { + throw new Error('PumpYourSound comment send button not found.'); + } + const href = send.getAttribute('data-href'); + if (href) { const u = new URL(href, location.origin); u.searchParams.set('text', text); send.setAttribute('data-href', `${u.pathname}${u.search}`); } - if (!send) { - throw new Error('PumpYourSound comment send button not found.'); - } send.click(); }, comment);