summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Credentials.php16
-rw-r--r--src/HistoryStorage.php41
-rw-r--r--src/HttpServer.php246
-rw-r--r--src/Request.php17
-rw-r--r--src/Response.php50
-rw-r--r--src/SpotifyApiClient.php140
-rw-r--r--src/TokenManager.php179
-rw-r--r--src/TokenStorage.php34
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));
+ }
+}