Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- [hitstar] Links to search on Spotify and open on Apple Music shown after reveal.
- [hitstar] Best score and active game state persisted in localStorage; interrupted games can be resumed.
- [hitstar] Abort button to cancel the current game at any time.

### Changed

- [hitstar] Reworked results screen with top/middle/bottom layout, colored per-round indicator squares (green/red), left border on result tiles, and removed score headline and Spotify/Apple Music links.
- [hitstar] Card border and year color now signal correct (green glow) or wrong (red glow) answer in reveal state; neutral border uses modern semi-transparent style.
- [hitstar] Shake animation on wrong answer moved from guessing screen to the card in reveal state.
- [hitstar] Switched backend track search from iTunes to Spotify API with OAuth2 client credentials token caching, market=DE filter, and `q=* year:1950-2025` query.
- [hitstar] Refactored GUESSING/REVEAL layout into top/middle/bottom sections: headline+exit+tracker pinned to top, card+audio centered vertically, year input pinned to bottom.
- [hitstar] Made HitstarCard fully responsive using `min()` and `aspect-ratio` instead of fixed pixel dimensions.
- [hitstar] Improved mobile layout: no horizontal or vertical scrollbars; components scale smaller on small screens.
- [frontend] Replaced `overflow: auto` on `main` with `overflow-x: hidden; overflow-y: auto` to prevent horizontal scrollbars.
- [jokes] Removed fixed height and margin from jokes container to avoid unwanted scroll areas.
- [global] Reduced GlobalMenu font sizes on mobile for better fit.
- [hitstar] Redesigned GUESSING/REVEAL UI: question headline + small danger Exit icon button in top bar, round progress tracker (gray/green/red bars), year input and submit icon button inline, submit icon centered via flex override.
- [global] Claude Code `/commit-push` Skill für automatisierten Commit-und-Push-Workflow.
- [global] Post-Commit Hook erweitert mit Unicode-Gitmojis und zusätzlichen Keywords (test, style, update, improve, move, breaking, access, database, responsive, animation, i18n, clean).
- [frontend] Added `viewport-fit=cover` to enable iPhone safe-area support (Notch/Home-Indicator) across all pages.
Expand Down
6 changes: 6 additions & 0 deletions backend/apps/tilloh-dev/src/env.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ export class EnvironmentVariables {

@IsString()
OCR_SPACE_API_KEY: string;

@IsString()
SPOTIFY_CLIENT_ID: string;

@IsString()
SPOTIFY_CLIENT_SECRET: string;
}

export function validate(
Expand Down
3 changes: 2 additions & 1 deletion backend/libs/spotify/src/lib/spotify.module.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { HttpModule } from '@nestjs/axios';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { SpotifyController } from './spotify.controller';
import { SpotifyService } from './spotify.service';

@Module({
imports: [HttpModule],
imports: [HttpModule, ConfigModule],
controllers: [SpotifyController],
providers: [SpotifyService],
exports: [],
Expand Down
61 changes: 40 additions & 21 deletions backend/libs/spotify/src/lib/spotify.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,34 @@
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { of } from 'rxjs';
import { SpotifyService } from './spotify.service';

const mockItunesResponse = {
const mockTokenResponse = {
data: {
resultCount: 1,
results: [
{
trackId: 123456789,
trackName: 'Test Song',
artistName: 'Artist A',
collectionName: 'Test Album',
artworkUrl100: 'https://example.com/100x100bb.jpg',
previewUrl: 'https://audio-ssl.itunes.apple.com/preview/abc123.m4a',
releaseDate: '1985-06-15T00:00:00Z',
trackViewUrl: 'https://music.apple.com/us/album/test-song/123456789',
},
],
access_token: 'mock_token_123',
expires_in: 3600,
},
};

const mockSpotifyResponse = {
data: {
tracks: {
items: [
{
id: 'abc123',
name: 'Test Song',
artists: [{ name: 'Artist A' }],
album: {
name: 'Test Album',
release_date: '1985-06-15',
images: [{ url: 'https://example.com/cover.jpg' }],
},
external_urls: { spotify: 'https://open.spotify.com/track/abc123' },
preview_url: 'https://audio.spotify.com/preview/abc123.mp3',
},
],
},
},
};

Expand All @@ -31,7 +42,11 @@ describe('SpotifyService', () => {
SpotifyService,
{
provide: HttpService,
useValue: { get: jest.fn() },
useValue: { get: jest.fn(), post: jest.fn() },
},
{
provide: ConfigService,
useValue: { get: jest.fn().mockReturnValue('mock_value') },
},
],
}).compile();
Expand All @@ -46,24 +61,28 @@ describe('SpotifyService', () => {

it('should return a SpotifyTrackDto on getRandomTrack()', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
jest.spyOn(httpService, 'get').mockReturnValue(of(mockItunesResponse as any));
jest.spyOn(httpService, 'post').mockReturnValue(of(mockTokenResponse as any));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
jest.spyOn(httpService, 'get').mockReturnValue(of(mockSpotifyResponse as any));

const result = await service.getRandomTrack();

expect(result.id).toBe('123456789');
expect(result.id).toBe('abc123');
expect(result.name).toBe('Test Song');
expect(result.artist).toBe('Artist A');
expect(result.album).toBe('Test Album');
expect(result.releaseYear).toBe(1985);
expect(result.albumCover).toBe('https://example.com/600x600bb.jpg');
expect(result.spotifyUrl).toBe('https://music.apple.com/us/album/test-song/123456789');
expect(result.previewUrl).toBe('https://audio-ssl.itunes.apple.com/preview/abc123.m4a');
expect(result.albumCover).toBe('https://example.com/cover.jpg');
expect(result.spotifyUrl).toBe('https://open.spotify.com/track/abc123');
expect(result.previewUrl).toBe('https://audio.spotify.com/preview/abc123.mp3');
});

it('should throw after 10 attempts when no results', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
jest.spyOn(httpService, 'post').mockReturnValue(of(mockTokenResponse as any));
jest.spyOn(httpService, 'get').mockReturnValue(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
of({ data: { resultCount: 0, results: [] } } as any),
of({ data: { tracks: { items: [] } } } as any),
);

await expect(service.getRandomTrack()).rejects.toThrow(
Expand Down
86 changes: 61 additions & 25 deletions backend/libs/spotify/src/lib/spotify.service.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,90 @@
import { SpotifyTrackDto } from '@backend/shared-types';
import { HttpService } from '@nestjs/axios';
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { firstValueFrom } from 'rxjs';

@Injectable()
export class SpotifyService {
private readonly logger = new Logger(SpotifyService.name);
private accessToken: string | null = null;
private tokenExpiresAt = 0;

constructor(private httpService: HttpService) {}
constructor(
private httpService: HttpService,
private configService: ConfigService,
) {}

private async getAccessToken(): Promise<string> {
if (this.accessToken && Date.now() < this.tokenExpiresAt - 60_000) {
return this.accessToken;
}

const clientId = this.configService.get<string>('SPOTIFY_CLIENT_ID');
const clientSecret = this.configService.get<string>('SPOTIFY_CLIENT_SECRET');
const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');

const response = await firstValueFrom(
this.httpService.post(
'https://accounts.spotify.com/api/token',
'grant_type=client_credentials',
{
headers: {
Authorization: `Basic ${credentials}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
},
),
);

this.accessToken = response.data.access_token as string;
this.tokenExpiresAt = Date.now() + (response.data.expires_in as number) * 1000;
return this.accessToken;
}

async getRandomTrack(): Promise<SpotifyTrackDto> {
const maxAttempts = 10;

for (let attempt = 0; attempt < maxAttempts; attempt++) {
const char = String.fromCharCode(97 + Math.floor(Math.random() * 26));
const offset = Math.floor(Math.random() * 50);
const token = await this.getAccessToken();

this.logger.log(`Attempt ${attempt + 1}: searching iTunes for char="${char}"`);
this.logger.log(`Attempt ${attempt + 1}: searching Spotify (offset=${offset})`);

try {
const searchUrl = `https://itunes.apple.com/search?term=${encodeURIComponent(char)}&entity=song&limit=20`;
const response = await firstValueFrom(this.httpService.get(searchUrl));
const searchUrl = `https://api.spotify.com/v1/search?q=*+year%3A1950-2025&type=track&market=DE&limit=1&offset=${offset}`;
const response = await firstValueFrom(
this.httpService.get(searchUrl, {
headers: { Authorization: `Bearer ${token}` },
}),
);

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const results: any[] = response.data?.results ?? [];

const validTracks = results.filter((track) => {
if (!track.previewUrl) return false;
if (!track.releaseDate) return false;
const year = parseInt((track.releaseDate as string).substring(0, 4), 10);
return !isNaN(year) && year >= 1950 && year <= 2026;
});
const items: any[] = response.data?.tracks?.items ?? [];
const validTracks = items.filter((t) => t.preview_url && t.album?.release_date);

if (validTracks.length > 0) {
const track = validTracks[Math.floor(Math.random() * validTracks.length)];
const releaseYear = parseInt((track.releaseDate as string).substring(0, 4), 10);
const albumCover = track.artworkUrl100
? (track.artworkUrl100 as string).replace('100x100bb', '600x600bb')
: '';
const track = validTracks[0];
const releaseYear = parseInt(
(track.album.release_date as string).substring(0, 4),
10,
);
const albumCover: string = track.album?.images?.[0]?.url ?? '';
const artist: string = (track.artists as { name: string }[])
.map((a) => a.name)
.join(', ');

this.logger.log(`Found: "${track.trackName}" by ${track.artistName} (${releaseYear})`);
this.logger.log(`Found: "${track.name as string}" by ${artist} (${releaseYear})`);

return {
id: String(track.trackId),
name: track.trackName as string,
artist: track.artistName as string,
album: track.collectionName as string,
id: track.id as string,
name: track.name as string,
artist,
album: track.album.name as string,
albumCover,
releaseYear,
spotifyUrl: track.trackViewUrl as string,
previewUrl: track.previewUrl as string,
spotifyUrl: (track.external_urls?.spotify as string) ?? '',
previewUrl: track.preview_url as string,
};
}

Expand Down
Loading
Loading