From a1efc5ac603d88d19e2377a9e68571af969cfd30 Mon Sep 17 00:00:00 2001 From: Razvan Iacob Date: Wed, 4 Nov 2026 12:54:11 -0500 Subject: [PATCH] feat: read what members are listening to from Last.fm --- .env.example | 2 + app/Actions/CreateExport.php | 1 + app/Actions/DeleteUserNowPlaying.php | 15 +++ app/Actions/UpdateUserNowPlaying.php | 15 +++ app/Contracts/Services/LastFmProvider.php | 21 +++ app/Dto/Track.php | 35 +++++ app/Enums/Feature.php | 3 +- .../Controllers/UserNowPlayingController.php | 38 ++++++ .../Requests/UpdateUserNowPlayingRequest.php | 32 +++++ app/Models/User.php | 4 + app/Providers/AppServiceProvider.php | 10 ++ app/Queries/NowPlayingQuery.php | 53 ++++++++ app/Rules/LastFmUsername.php | 42 ++++++ .../LastFmProviders/HttpLastFmProvider.php | 94 +++++++++++++ .../LastFmProviders/NullLastFmProvider.php | 21 +++ config/services.php | 4 + ...000_add_lastfm_username_to_users_table.php | 17 +++ resources/js/pages/operations/features.tsx | 5 + resources/js/types/operations.ts | 2 +- routes/web.php | 10 ++ .../Controllers/ExportControllerTest.php | 2 + .../Controllers/FeatureControllerTest.php | 6 +- .../UserNowPlayingControllerTest.php | 126 ++++++++++++++++++ .../Controllers/UserTimeoutControllerTest.php | 16 +++ tests/Feature/RateLimitTest.php | 14 ++ tests/Unit/Queries/NowPlayingQueryTest.php | 75 +++++++++++ .../HttpLastFmProviderTest.php | 121 +++++++++++++++++ .../NullLastFmProviderTest.php | 17 +++ 28 files changed, 796 insertions(+), 5 deletions(-) create mode 100644 app/Actions/DeleteUserNowPlaying.php create mode 100644 app/Actions/UpdateUserNowPlaying.php create mode 100644 app/Contracts/Services/LastFmProvider.php create mode 100644 app/Dto/Track.php create mode 100644 app/Http/Controllers/UserNowPlayingController.php create mode 100644 app/Http/Requests/UpdateUserNowPlayingRequest.php create mode 100644 app/Queries/NowPlayingQuery.php create mode 100644 app/Rules/LastFmUsername.php create mode 100644 app/Services/LastFmProviders/HttpLastFmProvider.php create mode 100644 app/Services/LastFmProviders/NullLastFmProvider.php create mode 100644 database/migrations/2026_11_04_120000_add_lastfm_username_to_users_table.php create mode 100644 tests/Feature/Controllers/UserNowPlayingControllerTest.php create mode 100644 tests/Unit/Queries/NowPlayingQueryTest.php create mode 100644 tests/Unit/Services/LastFmProviders/HttpLastFmProviderTest.php create mode 100644 tests/Unit/Services/LastFmProviders/NullLastFmProviderTest.php diff --git a/.env.example b/.env.example index 38a4f7fc8d..d782959920 100644 --- a/.env.example +++ b/.env.example @@ -89,6 +89,8 @@ GITHUB_REDIRECT_URI="${APP_URL}/auth/github/callback" GITHUB_APP_ID= GITHUB_APP_PRIVATE_KEY= +LASTFM_API_KEY= + # New GitHub accounts must be at least this many days old, and have a verified primary email, # before they can sign up. Both apply to the first sign-in only. SIGNUP_MIN_ACCOUNT_AGE_DAYS=7 diff --git a/app/Actions/CreateExport.php b/app/Actions/CreateExport.php index acab064116..6057b1ebc4 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -104,6 +104,7 @@ public function handle(User $user): array 'live_platform' => $user->live_platform?->value, 'live_url' => $user->live_url, 'live_ends_at' => $user->live_ends_at?->toIso8601String(), + 'lastfm_username' => $user->lastfm_username, 'last_seen_at' => $user->last_seen_at?->toDateString(), 'commit_rules_accepted_at' => $user->commit_rules_accepted_at?->toIso8601String(), 'emails_unsubscribed_at' => $user->emails_unsubscribed_at?->toIso8601String(), diff --git a/app/Actions/DeleteUserNowPlaying.php b/app/Actions/DeleteUserNowPlaying.php new file mode 100644 index 0000000000..4c5164bec6 --- /dev/null +++ b/app/Actions/DeleteUserNowPlaying.php @@ -0,0 +1,15 @@ +update(['lastfm_username' => null]); + } +} diff --git a/app/Actions/UpdateUserNowPlaying.php b/app/Actions/UpdateUserNowPlaying.php new file mode 100644 index 0000000000..30f46d2def --- /dev/null +++ b/app/Actions/UpdateUserNowPlaying.php @@ -0,0 +1,15 @@ +update(['lastfm_username' => $lastFmUsername]); + } +} diff --git a/app/Contracts/Services/LastFmProvider.php b/app/Contracts/Services/LastFmProvider.php new file mode 100644 index 0000000000..4adba33760 --- /dev/null +++ b/app/Contracts/Services/LastFmProvider.php @@ -0,0 +1,21 @@ + + */ +final readonly class Track implements Arrayable +{ + public function __construct( + public string $artist, + public string $title, + public ?string $album, + public ?string $url, + ) { + // + } + + /** + * @return array{artist: string, title: string, album: string|null, url: string|null} + */ + public function toArray(): array + { + return [ + 'artist' => $this->artist, + 'title' => $this->title, + 'album' => $this->album, + 'url' => $this->url, + ]; + } +} diff --git a/app/Enums/Feature.php b/app/Enums/Feature.php index 70485a003d..083b78fe8b 100644 --- a/app/Enums/Feature.php +++ b/app/Enums/Feature.php @@ -11,12 +11,13 @@ enum Feature: string { case Translations = 'translations'; case OrganizationCommits = 'organization_commits'; + case NowPlaying = 'now_playing'; /** * Features are on until switched off, except one still being built, which stays off until switched on. */ public function isEnabledByDefault(): bool { - return $this !== self::OrganizationCommits; + return ! in_array($this, [self::OrganizationCommits, self::NowPlaying], true); } } diff --git a/app/Http/Controllers/UserNowPlayingController.php b/app/Http/Controllers/UserNowPlayingController.php new file mode 100644 index 0000000000..7155add9e0 --- /dev/null +++ b/app/Http/Controllers/UserNowPlayingController.php @@ -0,0 +1,38 @@ +json([ + 'track' => $currentUser?->blockerIdWith($user) === null ? $nowPlaying->get($user) : null, + ])->header('Cache-Control', 'no-store, private'); + } + + public function update(UpdateUserNowPlayingRequest $request, #[CurrentUser] User $user, UpdateUserNowPlaying $action): RedirectResponse + { + $action->handle($user, $request->lastFmUsername()); + + return back(); + } + + public function destroy(#[CurrentUser] User $user, DeleteUserNowPlaying $action): RedirectResponse + { + $action->handle($user); + + return back(); + } +} diff --git a/app/Http/Requests/UpdateUserNowPlayingRequest.php b/app/Http/Requests/UpdateUserNowPlayingRequest.php new file mode 100644 index 0000000000..b71f20cedb --- /dev/null +++ b/app/Http/Requests/UpdateUserNowPlayingRequest.php @@ -0,0 +1,32 @@ +> + */ + public function rules(): array + { + return [ + 'lastfm_username' => ['bail', 'required', 'string', new LastFmUsername()], + ]; + } + + public function lastFmUsername(): string + { + return $this->string('lastfm_username')->value(); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index ab279ee5c9..6b97f6006e 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -57,6 +57,7 @@ * @property-read LivePlatform|null $live_platform * @property-read string|null $live_url * @property-read CarbonInterface|null $live_ends_at + * @property-read string|null $lastfm_username * @property-read Log $log * @property-read CarbonInterface|null $onboarded_at * @property-read CarbonInterface|null $github_follows_imported_at @@ -115,6 +116,7 @@ 'live_platform', 'live_url', 'live_ends_at', + 'lastfm_username', 'last_seen_at', 'banned_at', 'commit_rules_accepted_at', @@ -164,6 +166,7 @@ final class User extends Authenticatable 'live_platform' => null, 'live_url' => null, 'live_ends_at' => null, + 'lastfm_username' => null, 'last_seen_at' => null, 'banned_at' => null, 'commit_rules_accepted_at' => null, @@ -212,6 +215,7 @@ public function casts(): array 'live_platform' => LivePlatform::class, 'live_url' => 'string', 'live_ends_at' => 'datetime', + 'lastfm_username' => 'string', 'last_seen_at' => 'date', 'banned_at' => 'datetime', 'commit_rules_accepted_at' => 'datetime', diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index ae397a5eaf..db1d92e962 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -9,6 +9,7 @@ use App\Contracts\Services\EmbeddingProvider; use App\Contracts\Services\GitHubProvider; use App\Contracts\Services\HeadlineProvider; +use App\Contracts\Services\LastFmProvider; use App\Contracts\Services\LinkPreviewProvider; use App\Contracts\Services\ModerationProvider; use App\Contracts\Services\PushNotificationProvider; @@ -30,6 +31,8 @@ use App\Services\GitHubProviders\NullGitHubProvider; use App\Services\HeadlineProviders\NullHeadlineProvider; use App\Services\HeadlineProviders\OpenAIHeadlineProvider; +use App\Services\LastFmProviders\HttpLastFmProvider; +use App\Services\LastFmProviders\NullLastFmProvider; use App\Services\LinkPreviewProviders\HttpLinkPreviewProvider; use App\Services\LinkPreviewProviders\NullLinkPreviewProvider; use App\Services\ModerationProviders\NullModerationProvider; @@ -68,6 +71,7 @@ final class AppServiceProvider extends ServiceProvider EmbeddingProvider::class => OpenAIEmbeddingProvider::class, GitHubProvider::class => HttpGitHubProvider::class, HeadlineProvider::class => OpenAIHeadlineProvider::class, + LastFmProvider::class => HttpLastFmProvider::class, LinkPreviewProvider::class => HttpLinkPreviewProvider::class, ModerationProvider::class => OpenAIModerationProvider::class, PushNotificationProvider::class => WebPushNotificationProvider::class, @@ -88,6 +92,7 @@ final class AppServiceProvider extends ServiceProvider EmbeddingProvider::class => NullEmbeddingProvider::class, GitHubProvider::class => NullGitHubProvider::class, HeadlineProvider::class => NullHeadlineProvider::class, + LastFmProvider::class => NullLastFmProvider::class, LinkPreviewProvider::class => NullLinkPreviewProvider::class, ModerationProvider::class => NullModerationProvider::class, PushNotificationProvider::class => NullPushNotificationProvider::class, @@ -240,6 +245,11 @@ private function bootRateLimitingDefaults(): void $request->user() instanceof User ? $this->userId($request) : 'ip:'.$request->ip(), )); + RateLimiter::for('now-playing', fn (Request $request): Limit => Limit::perMinute(60)->by( + $request->user() instanceof User ? $this->userId($request) : 'ip:'.$request->ip(), + )); + RateLimiter::for('lastfm-links', fn (Request $request): Limit => Limit::perMinute(10)->by($this->userId($request))); + // Every issue lookup GitHub hasn't answered in the last five minutes costs a request out of the shared hourly budget, // which also keeps SSH keys in sync for the CLI, so it gets a budget of its own... RateLimiter::for('issue-suggestions', fn (Request $request): array => [ diff --git a/app/Queries/NowPlayingQuery.php b/app/Queries/NowPlayingQuery.php new file mode 100644 index 0000000000..189ed283d4 --- /dev/null +++ b/app/Queries/NowPlayingQuery.php @@ -0,0 +1,53 @@ +lastfm_username; + + if ($username === null || ! $this->features->enabled(Feature::NowPlaying)) { + return null; + } + + $key = 'lastfm-now-playing:'.mb_strtolower($username); + + /** @var array{artist: string, title: string, album: string|null, url: string|null}|false|null $track */ + $track = Cache::get($key); + + if ($track === null) { + try { + $track = $this->lastFm->nowPlaying($username)?->toArray() ?? false; + Cache::put($key, $track, self::TTL); + } catch (HttpClientException) { + $track = false; + Cache::put($key, $track, self::FAILURE_TTL); + } + } + + return $track === false ? null : $track; + } +} diff --git a/app/Rules/LastFmUsername.php b/app/Rules/LastFmUsername.php new file mode 100644 index 0000000000..f7e09dae52 --- /dev/null +++ b/app/Rules/LastFmUsername.php @@ -0,0 +1,42 @@ +enabled(Feature::NowPlaying)) { + $fail("Sharing what you're listening to isn't available yet."); + + return; + } + + if (! is_string($value) || preg_match('/^[A-Za-z][A-Za-z0-9_-]{1,14}$/', $value) !== 1) { + $fail('Use your Last.fm username: 2 to 15 letters, numbers, dashes or underscores, starting with a letter.'); + + return; + } + + try { + $exists = resolve(LastFmProvider::class)->exists($value); + } catch (HttpClientException) { + $fail("Last.fm isn't answering. Try again in a moment."); + + return; + } + + if (! $exists) { + $fail('There is no Last.fm account with that username.'); + } + } +} diff --git a/app/Services/LastFmProviders/HttpLastFmProvider.php b/app/Services/LastFmProviders/HttpLastFmProvider.php new file mode 100644 index 0000000000..9b59e5ea9e --- /dev/null +++ b/app/Services/LastFmProviders/HttpLastFmProvider.php @@ -0,0 +1,94 @@ +get('user.getinfo', $username); + + if ($response->json('error') === self::USER_NOT_FOUND) { + return false; + } + + throw_unless(is_string($this->successful($response)->json('user.name')), RequestException::class, $response); + + return true; + } + + public function nowPlaying(string $username): ?Track + { + $tracks = $this->successful($this->get('user.getrecenttracks', $username, ['limit' => 1]))->json('recenttracks.track'); + $track = is_array($tracks) && array_is_list($tracks) ? ($tracks[0] ?? null) : $tracks; + + if (! is_array($track) || data_get($track, '@attr.nowplaying') !== 'true') { + return null; + } + + $artist = $this->text(data_get($track, 'artist.#text')); + $title = $this->text(data_get($track, 'name')); + + if ($artist === null || $title === null) { + return null; + } + + return new Track( + artist: $artist, + title: $title, + album: $this->text(data_get($track, 'album.#text')), + url: $this->link(data_get($track, 'url')), + ); + } + + /** + * @param array $query + */ + private function get(string $method, string $username, array $query = []): Response + { + $key = config('services.lastfm.key'); + assert(is_string($key)); + + $appUrl = config('app.url'); + assert(is_string($appUrl)); + + return Http::withUserAgent("devnull/1.0 (+{$appUrl})") + ->acceptJson() + ->connectTimeout(3) + ->timeout(5) + ->get('https://ws.audioscrobbler.com/2.0/', [ + 'method' => $method, + 'user' => $username, + 'api_key' => $key, + 'format' => 'json', + ...$query, + ]); + } + + private function successful(Response $response): Response + { + throw_if($response->json('error') !== null, RequestException::class, $response); + + return $response->throw(); + } + + private function text(mixed $value): ?string + { + return is_string($value) && mb_trim($value) !== '' ? mb_trim($value) : null; + } + + private function link(mixed $value): ?string + { + return is_string($value) && str_starts_with($value, 'https://www.last.fm/') ? $value : null; + } +} diff --git a/app/Services/LastFmProviders/NullLastFmProvider.php b/app/Services/LastFmProviders/NullLastFmProvider.php new file mode 100644 index 0000000000..66c05dbb2b --- /dev/null +++ b/app/Services/LastFmProviders/NullLastFmProvider.php @@ -0,0 +1,21 @@ + env('GITHUB_APP_PRIVATE_KEY'), ], + 'lastfm' => [ + 'key' => env('LASTFM_API_KEY'), + ], + 'postmark' => [ 'key' => env('POSTMARK_API_KEY'), ], diff --git a/database/migrations/2026_11_04_120000_add_lastfm_username_to_users_table.php b/database/migrations/2026_11_04_120000_add_lastfm_username_to_users_table.php new file mode 100644 index 0000000000..cfd85d08e6 --- /dev/null +++ b/database/migrations/2026_11_04_120000_add_lastfm_username_to_users_table.php @@ -0,0 +1,17 @@ +string('lastfm_username', 15)->nullable(); + }); + } +}; diff --git a/resources/js/pages/operations/features.tsx b/resources/js/pages/operations/features.tsx index fdf9f63983..3da0c2d24d 100644 --- a/resources/js/pages/operations/features.tsx +++ b/resources/js/pages/operations/features.tsx @@ -23,6 +23,11 @@ const FEATURES: Record< description: "Members posting and replying as their organization, from the composer's Post as menu and the API. Off until the rest of organization commits ships; commits already posted as an organization stay up either way.", }, + now_playing: { + label: 'now playing', + description: + "Members linking a Last.fm account to share what they're listening to. Off until the rest of now playing ships; off also hides the tracks of accounts already linked.", + }, }; /** diff --git a/resources/js/types/operations.ts b/resources/js/types/operations.ts index 0751ad89b6..2567679776 100644 --- a/resources/js/types/operations.ts +++ b/resources/js/types/operations.ts @@ -36,7 +36,7 @@ export type Connectivity = { }; export type Feature = { - name: 'translations' | 'organization_commits'; + name: 'translations' | 'organization_commits' | 'now_playing'; enabled: boolean; }; diff --git a/routes/web.php b/routes/web.php index adb6d2c4db..3544ee19e8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -146,6 +146,7 @@ use App\Http\Controllers\UserLiveController; use App\Http\Controllers\UserMuteController; use App\Http\Controllers\UserMutualsController; +use App\Http\Controllers\UserNowPlayingController; use App\Http\Controllers\UserPasteisModeController; use App\Http\Controllers\UserPreviewController; use App\Http\Controllers\UserProfileController; @@ -307,6 +308,9 @@ Route::get('@{user:handle}/preview', [UserPreviewController::class, 'show']) ->middleware('throttle:people') ->name('user-preview.show'); +Route::get('@{user:handle}/now-playing', [UserNowPlayingController::class, 'show']) + ->middleware('throttle:now-playing') + ->name('user-now-playing.show'); Route::get('@{profile:handle}/og.png', [ProfileOpenGraphImageController::class, 'show']) ->name('user-open-graph-image.show'); Route::get('@{user:handle}/feed.atom', [UserFeedController::class, 'show']) @@ -678,6 +682,12 @@ ->name('user-live.update'); Route::delete('settings/live', [UserLiveController::class, 'destroy'])->name('user-live.destroy'); + // User Now Playing... + Route::put('settings/now-playing', [UserNowPlayingController::class, 'update']) + ->middleware([PreventPostingInTimeout::class, 'throttle:lastfm-links']) + ->name('user-now-playing.update'); + Route::delete('settings/now-playing', [UserNowPlayingController::class, 'destroy'])->name('user-now-playing.destroy'); + // Appearance... Route::get('settings/appearance', fn () => Inertia::render('appearance/update'))->name('appearance.edit'); diff --git a/tests/Feature/Controllers/ExportControllerTest.php b/tests/Feature/Controllers/ExportControllerTest.php index e706cc4035..0027b9e109 100644 --- a/tests/Feature/Controllers/ExportControllerTest.php +++ b/tests/Feature/Controllers/ExportControllerTest.php @@ -72,6 +72,7 @@ 'pasteis_received' => 17, 'pasteis_given_today' => 6, 'pasteis_given_on' => '2026-10-15', + 'lastfm_username' => 'zoe_listens', 'last_seen_at' => now()->toDateString(), 'remember_token' => 'never-export-this-token', ]); @@ -120,6 +121,7 @@ 'live_platform' => null, 'live_url' => null, 'live_ends_at' => null, + 'lastfm_username' => 'zoe_listens', 'last_seen_at' => now()->toDateString(), 'commit_rules_accepted_at' => $user->commit_rules_accepted_at->toIso8601String(), 'emails_unsubscribed_at' => null, diff --git a/tests/Feature/Controllers/FeatureControllerTest.php b/tests/Feature/Controllers/FeatureControllerTest.php index 53fc43a38c..7c8ea933fa 100644 --- a/tests/Feature/Controllers/FeatureControllerTest.php +++ b/tests/Feature/Controllers/FeatureControllerTest.php @@ -16,7 +16,7 @@ ->assertOk() ->assertInertia(fn ($page) => $page ->component('operations/features') - ->where('features', [['name' => 'translations', 'enabled' => true], ['name' => 'organization_commits', 'enabled' => false]])); + ->where('features', [['name' => 'translations', 'enabled' => true], ['name' => 'organization_commits', 'enabled' => false], ['name' => 'now_playing', 'enabled' => false]])); }); it('hides the features from non admins', function (): void { @@ -33,7 +33,7 @@ $admin = User::factory()->create(['handle' => 'admin']); $this->actingAs($admin)->get(route('feature.index')) - ->assertInertia(fn ($page) => $page->where('features', [['name' => 'translations', 'enabled' => true], ['name' => 'organization_commits', 'enabled' => false]])); + ->assertInertia(fn ($page) => $page->where('features', [['name' => 'translations', 'enabled' => true], ['name' => 'organization_commits', 'enabled' => false], ['name' => 'now_playing', 'enabled' => false]])); $this->fromRoute('feature.index') ->put(route('feature.update', 'translations'), ['enabled' => false]) @@ -42,7 +42,7 @@ expect(resolve(Features::class)->enabled(Feature::Translations))->toBeFalse(); $this->assertDatabaseHas('features', ['name' => 'translations', 'enabled' => false]); $this->get(route('feature.index')) - ->assertInertia(fn ($page) => $page->where('features', [['name' => 'translations', 'enabled' => false], ['name' => 'organization_commits', 'enabled' => false]])); + ->assertInertia(fn ($page) => $page->where('features', [['name' => 'translations', 'enabled' => false], ['name' => 'organization_commits', 'enabled' => false], ['name' => 'now_playing', 'enabled' => false]])); $this->put(route('feature.update', 'translations'), ['enabled' => true])->assertRedirect(); diff --git a/tests/Feature/Controllers/UserNowPlayingControllerTest.php b/tests/Feature/Controllers/UserNowPlayingControllerTest.php new file mode 100644 index 0000000000..b5982d1adb --- /dev/null +++ b/tests/Feature/Controllers/UserNowPlayingControllerTest.php @@ -0,0 +1,126 @@ +update(Feature::NowPlaying, true); +}); + +it('links a Last.fm account', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user)->fromRoute('user.show', $user) + ->put(route('user-now-playing.update'), ['lastfm_username' => ' RJ ']) + ->assertRedirectToRoute('user.show', $user); + + expect($user->refresh()->lastfm_username)->toBe('RJ'); +}); + +it('unlinks the Last.fm account', function (): void { + $user = User::factory()->create(['lastfm_username' => 'rj']); + + $this->actingAs($user)->fromRoute('user.show', $user) + ->delete(route('user-now-playing.destroy')) + ->assertRedirectToRoute('user.show', $user); + + expect($user->refresh()->lastfm_username)->toBeNull(); +}); + +it('rejects usernames Last.fm could not have', function (mixed $username): void { + $this->mock(LastFmProvider::class)->shouldNotReceive('exists'); + $user = User::factory()->create(); + + $this->actingAs($user)->putJson(route('user-now-playing.update'), ['lastfm_username' => $username]) + ->assertUnprocessable() + ->assertJsonValidationErrors('lastfm_username'); + + expect($user->refresh()->lastfm_username)->toBeNull(); +})->with([ + 'missing' => [null], + 'not a string' => [['rj']], + 'too short' => ['r'], + 'too long' => ['abcdefghijklmnop'], + 'starts with a number' => ['1rj'], + 'a link' => ['https://www.last.fm/user/rj'], +]); + +it('rejects a username nobody has on Last.fm', function (): void { + $this->mock(LastFmProvider::class)->shouldReceive('exists')->with('nobody')->andReturnFalse(); + $user = User::factory()->create(); + + $this->actingAs($user)->putJson(route('user-now-playing.update'), ['lastfm_username' => 'nobody']) + ->assertUnprocessable() + ->assertJsonValidationErrors(['lastfm_username' => 'There is no Last.fm account with that username.']); +}); + +it('asks to try again when Last.fm is not answering', function (): void { + $this->mock(LastFmProvider::class)->shouldReceive('exists')->andThrow(new ConnectionException('Timed out')); + $user = User::factory()->create(); + + $this->actingAs($user)->putJson(route('user-now-playing.update'), ['lastfm_username' => 'rj']) + ->assertUnprocessable() + ->assertJsonValidationErrors(['lastfm_username' => "Last.fm isn't answering. Try again in a moment."]); +}); + +it('cannot link an account until the feature is switched on', function (): void { + resolve(Features::class)->update(Feature::NowPlaying, false); + $user = User::factory()->create(); + + $this->actingAs($user)->putJson(route('user-now-playing.update'), ['lastfm_username' => 'rj']) + ->assertUnprocessable() + ->assertJsonValidationErrors(['lastfm_username' => "Sharing what you're listening to isn't available yet."]); + + expect($user->refresh()->lastfm_username)->toBeNull(); +}); + +it('requires authentication to link or unlink an account', function (): void { + $this->putJson(route('user-now-playing.update'), ['lastfm_username' => 'rj'])->assertUnauthorized(); + $this->deleteJson(route('user-now-playing.destroy'))->assertUnauthorized(); +}); + +it('shows anyone what a member is listening to', function (): void { + $this->mock(LastFmProvider::class)->shouldReceive('nowPlaying')->with('rj')->andReturn(new Track('Daft Punk', 'Digital Love', 'Discovery', 'https://www.last.fm/music/Daft+Punk/_/Digital+Love')); + $user = User::factory()->create(['lastfm_username' => 'rj']); + + $this->getJson(route('user-now-playing.show', $user)) + ->assertOk() + ->assertHeader('Cache-Control', 'no-store, private') + ->assertExactJson(['track' => [ + 'artist' => 'Daft Punk', + 'title' => 'Digital Love', + 'album' => 'Discovery', + 'url' => 'https://www.last.fm/music/Daft+Punk/_/Digital+Love', + ]]); + + $this->actingAs(User::factory()->create()) + ->getJson(route('user-now-playing.show', $user)) + ->assertJsonPath('track.title', 'Digital Love'); +}); + +it('shows nothing playing for a member without a linked account', function (): void { + $this->getJson(route('user-now-playing.show', User::factory()->create())) + ->assertOk() + ->assertExactJson(['track' => null]); +}); + +it('shows nothing playing between members with a block', function (Closure $block): void { + $this->mock(LastFmProvider::class)->shouldNotReceive('nowPlaying'); + $viewer = User::factory()->create(); + $user = User::factory()->create(['lastfm_username' => 'rj']); + $block($viewer, $user); + + $this->actingAs($viewer) + ->getJson(route('user-now-playing.show', $user)) + ->assertHeader('Cache-Control', 'no-store, private') + ->assertExactJson(['track' => null]); +})->with([ + 'you blocked them' => fn (User $viewer, User $user) => $viewer->blocking()->attach($user), + 'they blocked you' => fn (User $viewer, User $user) => $user->blocking()->attach($viewer), +]); diff --git a/tests/Feature/Controllers/UserTimeoutControllerTest.php b/tests/Feature/Controllers/UserTimeoutControllerTest.php index 770afcab35..92e3ccae7f 100644 --- a/tests/Feature/Controllers/UserTimeoutControllerTest.php +++ b/tests/Feature/Controllers/UserTimeoutControllerTest.php @@ -195,6 +195,22 @@ expect($user->refresh()->live_url)->toBeNull(); }); +it('stops a user in timeout from linking Last.fm, but lets them unlink it', function (): void { + $user = User::factory()->create(['lastfm_username' => 'rj']); + UserTimeout::factory()->for($user)->create(); + + $this->actingAs($user) + ->putJson(route('user-now-playing.update'), ['lastfm_username' => 'buymycourse']) + ->assertForbidden() + ->assertJsonPath('timeout.reason', 'Spam or self-promotion'); + + expect($user->refresh()->lastfm_username)->toBe('rj'); + + $this->delete(route('user-now-playing.destroy'))->assertRedirect(); + + expect($user->refresh()->lastfm_username)->toBeNull(); +}); + it('stops a user in timeout from starting, renaming, or growing orgs, and from messaging or pinning in them', function (): void { $user = User::factory()->create(); [$member, $newcomer] = followersOf($user, 2); diff --git a/tests/Feature/RateLimitTest.php b/tests/Feature/RateLimitTest.php index c49c278419..c26fc6a8bb 100644 --- a/tests/Feature/RateLimitTest.php +++ b/tests/Feature/RateLimitTest.php @@ -49,6 +49,20 @@ 120, 60, ], + 'now playing' => [ + fn (): array => [ + ['GET', route('user-now-playing.show', User::factory()->create())], + ], + 60, + 60, + ], + 'last.fm links' => [ + fn (): array => [ + ['PUT', route('user-now-playing.update')], + ], + 10, + 60, + ], 'issue suggestions' => [ fn (): array => [ ['GET', route('issue-suggestion.index', ['q' => '722'])], diff --git a/tests/Unit/Queries/NowPlayingQueryTest.php b/tests/Unit/Queries/NowPlayingQueryTest.php new file mode 100644 index 0000000000..8ef99f31b1 --- /dev/null +++ b/tests/Unit/Queries/NowPlayingQueryTest.php @@ -0,0 +1,75 @@ +update(Feature::NowPlaying, true); +}); + +it('reads what a member is listening to, and caches it for half a minute', function (): void { + $lastFm = $this->mock(LastFmProvider::class); + $lastFm->shouldReceive('nowPlaying')->with('RJ')->twice()->andReturn(new Track('Daft Punk', 'Digital Love', 'Discovery', 'https://www.last.fm/music/Daft+Punk/_/Digital+Love')); + $user = User::factory()->create(['lastfm_username' => 'RJ']); + $query = resolve(NowPlayingQuery::class); + + $track = ['artist' => 'Daft Punk', 'title' => 'Digital Love', 'album' => 'Discovery', 'url' => 'https://www.last.fm/music/Daft+Punk/_/Digital+Love']; + + expect($query->get($user))->toBe($track); + + $this->travel(29)->seconds(); + expect($query->get($user))->toBe($track); + + $this->travel(2)->seconds(); + $query->get($user); +}); + +it('remembers that nothing is playing for half a minute', function (): void { + $lastFm = $this->mock(LastFmProvider::class); + $lastFm->shouldReceive('nowPlaying')->twice()->andReturnNull(); + $user = User::factory()->create(['lastfm_username' => 'rj']); + $query = resolve(NowPlayingQuery::class); + + expect($query->get($user))->toBeNull(); + + $this->travel(29)->seconds(); + expect($query->get($user))->toBeNull(); + + $this->travel(2)->seconds(); + $query->get($user); +}); + +it('waits five minutes before asking Last.fm again after it failed', function (): void { + $lastFm = $this->mock(LastFmProvider::class); + $lastFm->shouldReceive('nowPlaying')->twice()->andThrow(new ConnectionException('Timed out')); + $user = User::factory()->create(['lastfm_username' => 'rj']); + $query = resolve(NowPlayingQuery::class); + + expect($query->get($user))->toBeNull(); + + $this->travel(299)->seconds(); + expect($query->get($user))->toBeNull(); + + $this->travel(2)->seconds(); + $query->get($user); +}); + +it('is null without asking Last.fm', function (Closure $setUp): void { + $this->mock(LastFmProvider::class)->shouldNotReceive('nowPlaying'); + + expect(resolve(NowPlayingQuery::class)->get($setUp()))->toBeNull(); +})->with([ + 'no linked account' => fn (): User => User::factory()->create(), + 'feature off' => function (): User { + resolve(Features::class)->update(Feature::NowPlaying, false); + + return User::factory()->create(['lastfm_username' => 'rj']); + }, +]); diff --git a/tests/Unit/Services/LastFmProviders/HttpLastFmProviderTest.php b/tests/Unit/Services/LastFmProviders/HttpLastFmProviderTest.php new file mode 100644 index 0000000000..32e51c58ed --- /dev/null +++ b/tests/Unit/Services/LastFmProviders/HttpLastFmProviderTest.php @@ -0,0 +1,121 @@ + 'api-key', 'app.url' => 'https://dev-null.sh']); +}); + +/** + * @param array $overrides + * @return array + */ +function lastFmTrack(array $overrides = []): array +{ + return [ + 'artist' => ['mbid' => '', '#text' => 'Daft Punk'], + 'streamable' => '0', + 'image' => [ + ['size' => 'small', '#text' => 'https://lastfm.freetls.fastly.net/i/u/34s/cover.jpg'], + ['size' => 'medium', '#text' => 'https://lastfm.freetls.fastly.net/i/u/64s/cover.jpg'], + ['size' => 'large', '#text' => 'https://lastfm.freetls.fastly.net/i/u/174s/cover.jpg'], + ['size' => 'extralarge', '#text' => 'https://lastfm.freetls.fastly.net/i/u/300x300/cover.jpg'], + ], + 'mbid' => '', + 'album' => ['mbid' => '', '#text' => 'Discovery'], + 'name' => 'Digital Love', + '@attr' => ['nowplaying' => 'true'], + 'url' => 'https://www.last.fm/music/Daft+Punk/_/Digital+Love', + ...$overrides, + ]; +} + +it('finds an account by its username', function (): void { + Http::fake(['ws.audioscrobbler.com/*' => Http::response(['user' => ['name' => 'RJ', 'url' => 'https://www.last.fm/user/RJ']])]); + + expect(new HttpLastFmProvider()->exists('rj'))->toBeTrue(); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://ws.audioscrobbler.com/2.0/?method=user.getinfo&user=rj&api_key=api-key&format=json' + && $request->header('User-Agent') === ['devnull/1.0 (+https://dev-null.sh)']); +}); + +it('finds no account for a username nobody has', function (array $body, int $status): void { + Http::fake(['ws.audioscrobbler.com/*' => Http::response($body, $status)]); + + expect(new HttpLastFmProvider()->exists('nobody'))->toBeFalse(); +})->with([ + 'not found' => [['message' => 'User not found', 'error' => 6], 404], + 'not found, but answered' => [['message' => 'User not found', 'error' => 6], 200], +]); + +it('fails to look up an account when Last.fm refuses', function (array|string $body, int $status): void { + Http::fake(['ws.audioscrobbler.com/*' => Http::response($body, $status)]); + + new HttpLastFmProvider()->exists('rj'); +})->with([ + 'with an error status' => [['message' => 'Invalid API key', 'error' => 10], 403], + 'while answering' => [['message' => 'Invalid API key', 'error' => 10], 200], + 'not found without saying why' => ['Not Found', 404], + 'answered without the account' => [[], 200], +])->throws(RequestException::class); + +it('reads the track playing now', function (): void { + Http::fake(['ws.audioscrobbler.com/*' => Http::response(['recenttracks' => ['track' => [ + lastFmTrack(), + lastFmTrack(['name' => 'Aerodynamic', '@attr' => null, 'date' => ['uts' => '1793800000']]), + ]]])]); + + expect(new HttpLastFmProvider()->nowPlaying('rj'))->toEqual(new Track( + artist: 'Daft Punk', + title: 'Digital Love', + album: 'Discovery', + url: 'https://www.last.fm/music/Daft+Punk/_/Digital+Love', + )); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=rj&api_key=api-key&format=json&limit=1'); +}); + +it('reads the track playing now when it is the only one', function (): void { + Http::fake(['ws.audioscrobbler.com/*' => Http::response(['recenttracks' => ['track' => lastFmTrack()]])]); + + expect(new HttpLastFmProvider()->nowPlaying('rj')?->title)->toBe('Digital Love'); +}); + +it('leaves out what is missing or does not point where it should', function (): void { + Http::fake(['ws.audioscrobbler.com/*' => Http::response(['recenttracks' => ['track' => [lastFmTrack([ + 'album' => ['mbid' => '', '#text' => ' '], + 'url' => 'javascript:alert(1)', + ])]]])]); + + expect(new HttpLastFmProvider()->nowPlaying('rj'))->toEqual(new Track('Daft Punk', 'Digital Love', null, null)); +}); + +it('is not playing anything', function (array $recentTracks): void { + Http::fake(['ws.audioscrobbler.com/*' => Http::response(['recenttracks' => $recentTracks])]); + + expect(new HttpLastFmProvider()->nowPlaying('rj'))->toBeNull(); +})->with([ + 'no tracks' => [['track' => []]], + 'only scrobbled' => [['track' => [lastFmTrack(['@attr' => null, 'date' => ['uts' => '1793800000']])]]], + 'no artist' => [['track' => [lastFmTrack(['artist' => ['mbid' => '', '#text' => '']])]]], + 'no title' => [['track' => [lastFmTrack(['name' => null])]]], +]); + +it('fails to read the track playing now when Last.fm cannot be reached', function (Closure|PromiseInterface $response): void { + Http::fake(['ws.audioscrobbler.com/*' => $response]); + + new HttpLastFmProvider()->nowPlaying('rj'); +})->with([ + 'hidden' => fn (): PromiseInterface => Http::response(['message' => 'Login: User required to be logged in', 'error' => 17], 403), + 'down' => fn (): PromiseInterface => Http::response([], 503), + 'failing while answering' => fn (): PromiseInterface => Http::response(['message' => 'Operation failed', 'error' => 8], 200), + 'unreachable' => fn (): Closure => Http::failedConnection(), +])->throws(HttpClientException::class); diff --git a/tests/Unit/Services/LastFmProviders/NullLastFmProviderTest.php b/tests/Unit/Services/LastFmProviders/NullLastFmProviderTest.php new file mode 100644 index 0000000000..47c0ec3663 --- /dev/null +++ b/tests/Unit/Services/LastFmProviders/NullLastFmProviderTest.php @@ -0,0 +1,17 @@ +toBeInstanceOf(NullLastFmProvider::class); +}); + +it('knows everyone, who are never listening to anything', function (): void { + $provider = new NullLastFmProvider(); + + expect($provider->exists('rj'))->toBeTrue() + ->and($provider->nowPlaying('rj'))->toBeNull(); +});