import express from 'express' import 'dotenv/config' import PQueue from 'p-queue' import { store, type AccessTokenResponse, type TokenResponse } from './storage.js' import { handleError, closeContexts, withPage } from './browser.js' import { delay, logMemory } from './utils.js' import { updateAllHashes, operations, updateHash } from './hashHandlers.js' import './cronjobs.js' const app = express() export const queue: PQueue = new PQueue({ concurrency: 1 }) app.use((req, res, next) => { logMemory(`--> Start ${req.method} ${req.url}`) res.on('finish', () => logMemory(`<-- End ${req.method} ${req.url}`)) next() }) app.get('/', (req, res) => { res.send('alive') }) app.use((req, res, next) => { if (req.headers['authorization'] !== process.env.API_SECRET) { return res.status(403).json({ error: 'Wrong Secret Key' }) } next() }) if (!process.env.API_SECRET || !process.env.SP_DC || !process.env.SP_KEY) { console.error('Error: Missing required environment variables. Please set API_SECRET, SP_DC, and SP_KEY.') process.exit(1) } // TODO give userId // TODO give sha codes on 401 / !412! error on client. separate route function isTokenValid(): boolean { return !!( store.access && store.access.accessTokenExpirationTimestampMs > Date.now() && store.client && store.client.expiresAt > Date.now() ) } app.get('/token', async (req, res) => { // return token if valid if (isTokenValid()) { console.log('Returning cached data') return res.json({ access: store.access!, client: store.client! } satisfies TokenResponse) } const result = await queue.add( async () => { // if there was a request before, check it's result before making new one if (isTokenValid()) { console.log('Returning cached data') return { access: store.access!, client: store.client! } satisfies TokenResponse } return await withPage(async (page) => { // access token const accessTokenPromise = page .waitForResponse(async (res) => res.url().includes('https://open.spotify.com/api/token') && res.status() === 200) .then(async (res) => res.json() as Promise) // client token const clientTokenPromise = page .waitForResponse( async (res) => res.url().includes('https://clienttoken.spotify.com/v1/clienttoken') && res.status() === 200, ) .then(async (res) => { const json = await res.json().catch(() => null) const req = res.request() const payload = req.postDataJSON() return { ...json.granted_token, client_version: payload.client_data.client_version, // headers: req.headers(), } }) // get tokens const [accessTokenRes, clientTokenRes] = await Promise.all([ accessTokenPromise, clientTokenPromise, page.goto('https://open.spotify.com/', { waitUntil: 'domcontentloaded', timeout: 60000 }), ]) console.log('Token obtained successfully') // format data store.access = accessTokenRes store.client = { expiresAt: Date.now() + clientTokenRes.refresh_after_seconds * 1000, token: clientTokenRes.token, version: clientTokenRes.client_version, } // store.headers = clientTokenRes.headers return { access: store.access!, client: store.client! } satisfies TokenResponse }) }, { priority: 1 }, ) if (!result) throw new Error('Failed to obtain token') res.json(result) }) // TODO handle 404 app.get('/hashes', (req, res) => { const raw = String(req.query.names || '') if (raw) { const names = raw.split(',') const filtered = Object.fromEntries(Object.entries(store.hashes).filter(([key]) => names.includes(key))) res.json({ requested: filtered, all: store.hashes }) } else { res.json({ requested: {}, all: store.hashes }) } }) app.put('/hashes', async (req, res) => { const raw = String(req.query.names || '') let tempHash = store.tempHashes const names = raw ? raw.split(',') : null if (!names) { // update all tempHash = await updateAllHashes() } else { // update selected for (const name of names) { const op = operations.find((o) => o.names.includes(name)) if (!op || op.names.every((name) => tempHash[name])) { console.log(`Skipping ${name}`) continue } const hash = await queue.add(() => withPage((page) => updateHash(page, op))) // Record hash for ALL names in the operation for (const opName of op.names) tempHash[opName] = hash await delay(800) } const hashesAmount = Object.keys(tempHash).length if (hashesAmount === 0) { return res.status(400).json({ error: 'No valid operation names provided' }) } if (names.some((name) => !operations.some((op) => op.names.includes(name)))) { return res.status(404).json({ error: 'Some operation names were invalid', details: { raw, hashes: tempHash }, }) } } if (Object.keys(tempHash).some((key) => tempHash[key] === null)) { console.error('Failed to update some hashes', tempHash) return res.status(502).json({ error: 'Failed to update some hashes', details: tempHash }) } store.tempHashes = {} Object.assign(store.hashes, tempHash) const requested = Object.fromEntries(Object.entries(store.hashes).filter(([key, value]) => names?.includes(key) && value)) res.json({ requested, all: store.hashes }) }) // middleware --next-> failed route --next(err?)-> error handler app.use(async (err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => { await closeContexts(store.browser) const details = handleError(err) if (!res.headersSent) { res.status(500).json({ error: 'Internal server error', details }) } }) // TypeScript / Node.js const portRaw = process.env.PORT ?? "3000"; const PORT = Number.parseInt(portRaw, 10); if (!Number.isFinite(PORT) || PORT <= 0) { throw new Error(`Invalid PORT: ${portRaw}`); } app.listen(PORT, "0.0.0.0", () => { console.log(`Server listening on 0.0.0.0:${PORT}`); }); process.on('unhandledRejection', (reason) => { console.error('💥 Unhandled Promise Rejection:', reason) }) process.on('uncaughtException', (err) => { console.error('💥 Uncaught Exception:', err) })