From b9c4f1dcf1333c14e2d1b67561ec7a5f955baa6e Mon Sep 17 00:00:00 2001 From: "David T. Sadler" Date: Sun, 9 Aug 2026 20:04:39 +0100 Subject: Initial commit --- src/Credentials.php | 16 +++ src/HistoryStorage.php | 41 ++++++++ src/HttpServer.php | 246 +++++++++++++++++++++++++++++++++++++++++++++++ src/Request.php | 17 ++++ src/Response.php | 50 ++++++++++ src/SpotifyApiClient.php | 140 +++++++++++++++++++++++++++ src/TokenManager.php | 179 ++++++++++++++++++++++++++++++++++ src/TokenStorage.php | 34 +++++++ 8 files changed, 723 insertions(+) create mode 100644 src/Credentials.php create mode 100644 src/HistoryStorage.php create mode 100644 src/HttpServer.php create mode 100644 src/Request.php create mode 100644 src/Response.php create mode 100644 src/SpotifyApiClient.php create mode 100644 src/TokenManager.php create mode 100644 src/TokenStorage.php (limited to 'src') diff --git a/src/Credentials.php b/src/Credentials.php new file mode 100644 index 0000000..bcc4a53 --- /dev/null +++ b/src/Credentials.php @@ -0,0 +1,16 @@ +filename, json_encode($this, JSON_PRETTY_PRINT)); + } + + public function saveSong(array $song): void + { + $this->history[$song['id']] = [ + 'timestamp' => time(), + 'details' => $song, + ]; + $this->save(); + } +} diff --git a/src/HttpServer.php b/src/HttpServer.php new file mode 100644 index 0000000..bdab9cc --- /dev/null +++ b/src/HttpServer.php @@ -0,0 +1,246 @@ +handlers[$uri][$method] = $handler; + } + + public function get(string $uri, callable $handler): void + { + $this->on('GET', $uri, $handler); + } + + public function post(string $uri, callable $handler): void + { + $this->on('POST', $uri, $handler); + } + + /** + * Allows a route handler to trigger server shutdown after responding. + */ + public function closeAfterResponse(): void + { + $this->shouldClose = true; + } + + public function run(): void + { + $socket = stream_socket_server( + "tcp://$this->address:$this->port", + $errno, + $errstr, + STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, + ); + + if ($socket === false) { + echo "errstr ($errno)\n"; + exit(1); + } + + while ($socket) { + $stream = stream_socket_accept($socket, $this->timeout); + + if ($stream === false) { + continue; + } + + $request = $this->processRequest($stream); + + if (!$request) { + fclose($stream); + continue; + } + + $response = $this->processResponse($request); + + fwrite($stream, (string) $response); + fclose($stream); + + // Shutdown if requested by handler. + if ($this->shouldClose) { + break; + } + } + + fclose($socket); + } + + /** + * @param resource $stream + */ + private function processRequest($stream): ?Request + { + $lines = []; + + while ($line = fgets($stream)) { + $line = rtrim($line, "\r\n"); + if ($line !== '') { + $lines[] = $line; + continue; + } + + break; + } + + if (empty($lines)) { + return null; + } + + // Process start line. + [$method, $uri, $httpVersion] = array_pad( + explode(' ', array_shift($lines)), + 3, + '', + ); + + // Process uri. + $uri = explode('?', $uri, 2); + $path = $this->normalisePath(urldecode($uri[0])); + $params = []; + parse_str($uri[1] ?? '', $params); + + // Process headers. + $headers = array_reduce($lines, function (array $carry, string $line): array { + $parts = explode(':', $line, 2); + $header = strtolower(trim($parts[0])); + $carry[$header] = trim($parts[1] ?? ''); + + return $carry; + }, []); + + // Process the body. + $body = ''; + $contentLength = (int) ($headers['content-length'] ?? 0); + + if ($contentLength > 0) { + $remaining = $contentLength; + + do { + $buffer = fread($stream, $remaining); + + if ($buffer === false || $buffer === '') { + break; + } + + $body .= $buffer; + $bytesRead = strlen($body); + $remaining = $contentLength - $bytesRead; + } while ($bytesRead < $contentLength); + } + + return new Request( + method: $method, + path: $path, + httpVersion: $httpVersion, + headers: $headers, + params: $params, + body: $body, + ); + } + + private function processResponse(Request $request): Response + { + try { + $pathHandlers = $this->handlers[$request->path] ?? null; + + if ($pathHandlers !== null) { + $handler = $pathHandlers[$request->method] ?? null; + + if ($handler === null) { + $allowedMethods = implode(', ', array_keys($pathHandlers)); + + return new Response( + statusCode: 405, + contentType: 'text/plain', + body: '405 Method Not Allowed', + headers: [ + 'Allow' => $allowedMethods, + ], + ); + } + + return $handler($request, $this); + } + + // Path not found in handlers -> Fall back to static files or 404. + $path = ltrim($request->path, '/'); + if (is_file($path)) { + $statusCode = 200; + $contentType = $this->mimeType($path); + $body = file_get_contents($path); + } else { + $statusCode = 404; + $contentType = 'text/plain'; + $body = "Could not locate [$path]"; + } + } catch (Throwable $exception) { + $statusCode = 500; + $contentType = 'text/plain'; + $body = $exception->getMessage(); + } + + return new Response( + statusCode: $statusCode, + contentType: $contentType, + body: $body, + ); + } + + private function normalisePath(string $path): string + { + $path = "/$path"; + + $parts = explode('/', $path); + + $normalised = []; + + foreach ($parts as $part) { + switch ($part) { + case '': + case '.': + break; + case '..': + array_pop($normalised); + break; + default: + $normalised[] = $part; + break; + } + } + + return '/' . implode('/', $normalised); + } + + private function mimeType(string $path): string + { + return match (pathinfo($path, PATHINFO_EXTENSION)) { + 'html' => 'text/html', + 'jpeg' => 'image/jpeg', + 'png' => 'image/png', + 'txt' => 'text/plain', + 'css' => 'text/css', + 'js' => 'text/javascript', + 'json' => 'application/json', + default => 'application/octet-stream', + }; + } +} diff --git a/src/Request.php b/src/Request.php new file mode 100644 index 0000000..9b4bd5d --- /dev/null +++ b/src/Request.php @@ -0,0 +1,17 @@ + $headers Key-value pairs of additional HTTP headers + */ + public function __construct( + public readonly int $statusCode, + public readonly string $contentType, + public readonly string $body, + public readonly array $headers = [], + ) {} + + public function __toString(): string + { + $reason = match ($this->statusCode) { + 200 => 'OK', + 400 => 'Bad Request', + 401 => 'Unauthorized', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 500 => 'Internal Server Error', + default => '', + }; + + $statusLine = trim("HTTP/1.1 {$this->statusCode} {$reason}"); + $contentLength = strlen($this->body); + + // Build base headers. + $formattedHeaders = [ + $statusLine, + "Content-Type: $this->contentType", + "Content-Length: $contentLength", + "Connection: close", + ]; + + // Format and append custom headers. + foreach ($this->headers as $name => $value) { + $formattedHeaders[] = "$name: $value"; + } + + return implode("\r\n", $formattedHeaders) . "\r\n\r\n" . $this->body; + } +} diff --git a/src/SpotifyApiClient.php b/src/SpotifyApiClient.php new file mode 100644 index 0000000..a78f05b --- /dev/null +++ b/src/SpotifyApiClient.php @@ -0,0 +1,140 @@ +historyStorage->playlists) + || $this->historyStorage->playlists[$playlistName] === '' + ) { + $this->getPlaylistID($playlistName); + } + + $items = $this->getPlaylistItems($this->historyStorage->playlists[$playlistName]); + + if (!count($items)) { + echo "Playlist appears empty.\n"; + exit(1); + } + + $items = array_values(array_filter($items, function (array $item): bool { + return !array_key_exists($item['id'], $this->historyStorage->history); + })); + + if (!count($items)) { + echo "No songs left to pick.\n"; + exit(1); + } + + $key = array_rand($items); + + $song = $items[$key]; + + $this->historyStorage->saveSong($song); + + return $song; + } + + private function getPlaylistID(string $playlistName): void + { + echo "Looking up the Spotify playlist ID.\n"; + + $url = 'https://api.spotify.com/v1/me/playlists?limit=50'; + + $ch = curl_init(); + + while ($url !== null) { + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_HTTPGET => true, + CURLOPT_HTTPHEADER => [ + "Authorization: Bearer {$this->tokenManager->getAccessToken()}", + ], + CURLOPT_RETURNTRANSFER => true, + ]); + + $response = curl_exec($ch); + $statusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + + if ($statusCode !== 200) { + echo "Non-200 response from Spotify: $statusCode $response\n"; + exit(1); + } + + $data = json_decode($response, true) ?? []; + + foreach ($data['items'] ?? [] as $item) { + if ($item['name'] === $playlistName) { + $this->historyStorage->playlists[$playlistName] = $item['id']; + $this->historyStorage->save(); + + return; + } + } + + $url = $data['next'] ?? null; + } + + echo "Unable to find a playlist named $playlistName.\n"; + exit(1); + } + + private function getPlaylistItems(string $playlistID): array + { + $items = []; + + $query = http_build_query([ + 'fields' => 'next,items(item(id,name,artists(name)))', + ]); + + $url = "https://api.spotify.com/v1/playlists/$playlistID/items?limit=50&$query"; + + $ch = curl_init(); + + while ($url !== null) { + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_HTTPGET => true, + CURLOPT_HTTPHEADER => [ + "Authorization: Bearer {$this->tokenManager->getAccessToken()}", + ], + CURLOPT_RETURNTRANSFER => true, + ]); + + $response = curl_exec($ch); + $statusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + + if ($statusCode !== 200) { + echo "Non-200 response from Spotify: $statusCode $response\n"; + exit(1); + } + + $data = json_decode($response, true) ?? []; + + foreach ($data['items'] ?? [] as $item) { + if (isset($item['item'])) { + $items[] = $item['item']; + } + } + + $url = $data['next'] ?? null; + } + + return $items; + } +} diff --git a/src/TokenManager.php b/src/TokenManager.php new file mode 100644 index 0000000..96b3969 --- /dev/null +++ b/src/TokenManager.php @@ -0,0 +1,179 @@ +storage->accessToken === '' || $this->storage->refreshToken === '') { + $this->authorise(); + $this->requestAccessToken(); + } + + if ($this->storage->expiresOn < time()) { + $this->refreshAccessToken(); + } + + return $this->storage->accessToken; + } + + private function authorise(): void + { + $this->authorisationCode = ''; + + $this->httpServer->get( + '/callback', + function (Request $request, HttpServer $server): Response { + $server->closeAfterResponse(); + + if ( + !array_key_exists('state', $request->params) + || $request->params['state'] !== $this->credentials->state + ) { + $body = 'State mismatch.'; + } elseif (array_key_exists('error', $request->params)) { + $body = "Error from Spotify: {$request->params['error']}"; + } elseif (!array_key_exists('code', $request->params)) { + $body = "Missing authorisation code from Spotify."; + } else { + $this->authorisationCode = $request->params['code']; + $body = 'Authorisation code received from Spotify. You can close this page and go back to the terminal.'; + } + + return new Response( + statusCode: 200, + contentType: 'text/plain; charset=UTF-8', + body: htmlspecialchars($body, ENT_QUOTES), + ); + }, + ); + + $query = http_build_query([ + 'response_type' => 'code', + 'client_id' => $this->credentials->clientID, + 'redirect_uri' => $this->credentials->redirectURI, + 'state' => $this->credentials->state, + 'scope' => 'playlist-read-private', + 'show_dialog' => false, + ]); + + echo "Visit the below URL in your browser to authorise access to Spotify.\n"; + echo "https://accounts.spotify.com/authorize?$query\n"; + echo "Waiting for authorisation from Spotify...\n"; + + $this->httpServer->run(); + + if ($this->authorisationCode === '') { + echo "Failed to get authorisation code from Spotify.\n"; + exit(1); + } + } + + private function requestAccessToken(): void + { + echo "Requesting an access token from Spotify.\n"; + + $ch = curl_init(); + + curl_setopt_array($ch, [ + CURLOPT_URL => 'https://accounts.spotify.com/api/token', + CURLOPT_POST => true, + CURLOPT_HTTPHEADER => [ + 'Authorization: Basic ' . base64_encode($this->credentials->clientID . ':' . $this->credentials->clientSecret), + 'Content-Type: application/x-www-form-urlencoded', + ], + CURLOPT_POSTFIELDS => http_build_query([ + 'grant_type' => 'authorization_code', + 'code' => $this->authorisationCode, + 'redirect_uri' => $this->credentials->redirectURI, + ]), + CURLOPT_RETURNTRANSFER => true, + ]); + + $response = curl_exec($ch); + $statusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + + if ($statusCode !== 200) { + echo "Non 200 response from Spotify: $statusCode $response\n"; + exit(1); + } + + $data = json_decode($response, true) ?? []; + + $accessToken = $data['access_token'] ?? ''; + $refreshToken = $data['refresh_token'] ?? ''; + $expiresIn = $data['expires_in'] ?? 0; + + if ($accessToken === '' || $refreshToken === '') { + echo "Failed to get access token from Spotify.\n"; + exit(1); + } + + $this->storage->accessToken = $accessToken; + $this->storage->refreshToken = $refreshToken; + $this->storage->expiresOn = time() + $expiresIn; + $this->storage->save(); + } + + private function refreshAccessToken(): void + { + echo "Refreshing the Spotify access token.\n"; + + $ch = curl_init(); + + curl_setopt_array($ch, [ + CURLOPT_URL => 'https://accounts.spotify.com/api/token', + CURLOPT_POST => true, + CURLOPT_HTTPHEADER => [ + 'Authorization: Basic ' . base64_encode($this->credentials->clientID . ':' . $this->credentials->clientSecret), + 'Content-Type: application/x-www-form-urlencoded', + ], + CURLOPT_POSTFIELDS => http_build_query([ + 'grant_type' => 'refresh_token', + 'refresh_token' => $this->storage->refreshToken, + ]), + CURLOPT_RETURNTRANSFER => true, + ]); + + $response = curl_exec($ch); + $statusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + + if ($statusCode !== 200) { + echo "Non 200 response from Spotify: $statusCode $response\n"; + exit(1); + } + + $data = json_decode($response, true) ?? []; + + $accessToken = $data['access_token'] ?? ''; + $refreshToken = $data['refresh_token'] ?? $this->storage->refreshToken; + $expiresIn = $data['expires_in'] ?? 0; + + if ($accessToken === '' || $refreshToken === '') { + echo "Failed to refresh the Spotify access token.\n"; + exit(1); + } + + $this->storage->accessToken = $accessToken; + $this->storage->refreshToken = $refreshToken; + $this->storage->expiresOn = time() + $expiresIn; + $this->storage->save(); + } +} diff --git a/src/TokenStorage.php b/src/TokenStorage.php new file mode 100644 index 0000000..d7b8db8 --- /dev/null +++ b/src/TokenStorage.php @@ -0,0 +1,34 @@ +filename, json_encode($this, JSON_PRETTY_PRINT)); + } +} -- cgit v1.2.3-13-gbd6f