File size: 6,809 Bytes
e24a52f
 
 
c22439c
 
4170f15
e24a52f
c22439c
e24a52f
 
78a24ef
e24a52f
4170f15
 
 
 
 
 
e5619c2
 
 
 
e24a52f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c22439c
 
 
 
 
 
4170f15
 
 
 
 
 
e24a52f
4170f15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30df607
4170f15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30df607
4170f15
 
 
 
 
c22439c
94f96fe
c22439c
e24a52f
 
30df607
e5619c2
4170f15
e5619c2
 
 
e7a9263
e5619c2
e7a9263
e5619c2
 
 
 
4170f15
78a24ef
e24a52f
78a24ef
 
 
e24a52f
78a24ef
e24a52f
 
 
78a24ef
 
 
 
 
 
c22439c
78a24ef
 
e24a52f
 
78a24ef
 
e5619c2
e24a52f
78a24ef
94f96fe
 
78a24ef
94f96fe
e24a52f
 
 
78a24ef
 
 
e24a52f
 
78a24ef
 
 
 
e24a52f
 
c22439c
 
 
 
 
 
 
e24a52f
 
19df151
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30df607
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
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<TokenResponse>(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<AccessTokenResponse>)

            // 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)
})