diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/Credentials.php | 16 | ||||
| -rw-r--r-- | src/HistoryStorage.php | 41 | ||||
| -rw-r--r-- | src/HttpServer.php | 246 | ||||
| -rw-r--r-- | src/Request.php | 17 | ||||
| -rw-r--r-- | src/Response.php | 50 | ||||
| -rw-r--r-- | src/SpotifyApiClient.php | 140 | ||||
| -rw-r--r-- | src/TokenManager.php | 179 | ||||
| -rw-r--r-- | src/TokenStorage.php | 34 |
8 files changed, 723 insertions, 0 deletions
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 @@ +<?php + +declare(strict_types=1); + +namespace TCB; + +final class Credentials +{ + public function __construct( + public readonly string $clientID, + public readonly string $clientSecret, + public readonly string $redirectURI, + public readonly string $state, + ) { + } +} diff --git a/src/HistoryStorage.php b/src/HistoryStorage.php new file mode 100644 index 0000000..30d9ace --- /dev/null +++ b/src/HistoryStorage.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +namespace TCB; + +final class HistoryStorage +{ + public function __construct( + public array $playlists, + public array $history, + private readonly string $filename, + ) {} + + static public function load(string $filename): self + { + $storage = is_readable($filename) + ? json_decode(file_get_contents($filename), true) + : []; + + return new self( + playlists: $storage['playlists'] ?? [], + history: $storage['history'] ?? [], + filename: $filename, + ); + } + + public function save(): void + { + file_put_contents($this->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 @@ +<?php + +declare(strict_types=1); + +namespace TCB; + +use TCB\Request; +use TCB\Response; +use Throwable; + +final class HttpServer +{ + private array $handlers = []; + private bool $shouldClose = false; + + public function __construct( + private readonly string $address, + private readonly int $port, + private readonly int $timeout, + ) {} + + public function on(string $method, string $uri, callable $handler): void + { + $this->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 @@ +<?php + +declare(strict_types=1); + +namespace TCB; + +final class Request +{ + public function __construct( + public readonly string $method, + public readonly string $path, + public readonly string $httpVersion, + public readonly array $headers, + public readonly array $params, + public readonly string $body, + ) {} +} diff --git a/src/Response.php b/src/Response.php new file mode 100644 index 0000000..d3c4f69 --- /dev/null +++ b/src/Response.php @@ -0,0 +1,50 @@ +<?php + +declare(strict_types=1); + +namespace TCB; + +final class Response +{ + /** + * @param array<string, string> $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 @@ +<?php + +declare(strict_types=1); + +namespace TCB; + +use TCB\TokenManager; +use TCB\HistoryStorage; + +final class SpotifyApiClient +{ + public function __construct( + private readonly TokenManager $tokenManager, + private readonly HistoryStorage $historyStorage, + ) {} + + public function pickRandomSongFromPlaylist(string $playlistName): array + { + echo "Picking a random song from the playlist $playlistName.\n"; + + if ( + !array_key_exists($playlistName, $this->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 @@ +<?php + +declare(strict_types=1); + +namespace TCB; + +use TCB\Credentials; +use TCB\HttpServer; +use TCB\Request; +use TCB\Response; +use TCB\TokenStorage; + +final class TokenManager +{ + private string $authorisationCode; + + public function __construct( + private readonly Credentials $credentials, + private readonly HttpServer $httpServer, + private readonly TokenStorage $storage, + ) {} + + public function getAccessToken(): string + { + if ($this->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 @@ +<?php + +declare(strict_types=1); + +namespace TCB; + +final class TokenStorage +{ + public function __construct( + public string $accessToken, + public string $refreshToken, + public int $expiresOn, + private readonly string $filename, + ) {} + + static public function load(string $filename): self + { + $storage = is_readable($filename) + ? json_decode(file_get_contents($filename), true) + : []; + + return new self( + accessToken: $storage['accessToken'] ?? '', + refreshToken: $storage['refreshToken'] ?? '', + expiresOn: $storage['expiresOn'] ?? 0, + filename: $filename, + ); + } + + public function save(): void + { + file_put_contents($this->filename, json_encode($this, JSON_PRETTY_PRINT)); + } +} |
