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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- [hitstar] New "Hitstar" music year guessing game: listen to a 30-second preview and guess the release year across 10 rounds.
- [hitstar] Backend NX library `spotify` with iTunes Search API integration and random track endpoint (`GET /spotify/random-track`).
- [hitstar] Frontend route `/hitstar` with state machine (MENU → LOADING → GUESSING → REVEAL → RESULTS).
- [hitstar] Audio preview plays automatically in GUESSING state via iTunes preview URL.
- [hitstar] CSS 3D flip card animation on answer reveal, confetti for correct guesses, shake animation for wrong answers.
- [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.
- [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
2 changes: 2 additions & 0 deletions backend/apps/tilloh-dev/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AdminModule } from '@backend/admin';
import { JokesModule } from '@backend/jokes';
import { SpotifyModule } from '@backend/spotify';
import { MemorandumModule } from '@backend/memorandum';
import { OcrModule } from '@backend/ocr';
import { SharedControllerHealthModule } from '@backend/shared-controller-health';
Expand Down Expand Up @@ -71,6 +72,7 @@ import { EnvironmentVariables, validate } from './env.validation';
MemorandumModule,
JokesModule,
OcrModule,
SpotifyModule,
TodoControllerModule,
],
providers: [
Expand Down
1 change: 1 addition & 0 deletions backend/libs/shared/common/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ export * from './lib/identifiers.dto';
export * from './lib/jokes.dto';
export * from './lib/keystore.dto';
export * from './lib/ocr-space.dto';
export * from './lib/spotify.dto';
export * from './lib/todo.dto';
27 changes: 27 additions & 0 deletions backend/libs/shared/common/types/src/lib/spotify.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { ApiProperty } from '@nestjs/swagger';

export class SpotifyTrackDto {
@ApiProperty({ description: 'Spotify track ID' })
id: string;

@ApiProperty({ description: 'Track name' })
name: string;

@ApiProperty({ description: 'Comma-separated artist names' })
artist: string;

@ApiProperty({ description: 'Album name' })
album: string;

@ApiProperty({ description: 'Album cover image URL' })
albumCover: string;

@ApiProperty({ description: 'Release year of the track' })
releaseYear: number;

@ApiProperty({ description: 'Spotify track URL' })
spotifyUrl: string;

@ApiProperty({ description: '30-second MP3 preview URL (null if not available)' })
previewUrl: string | null;
}
18 changes: 18 additions & 0 deletions backend/libs/spotify/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"extends": ["../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}
11 changes: 11 additions & 0 deletions backend/libs/spotify/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/* eslint-disable */
export default {
displayName: 'spotify',
preset: '../../jest.preset.js',
testEnvironment: 'node',
transform: {
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../coverage/libs/spotify',
};
24 changes: 24 additions & 0 deletions backend/libs/spotify/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "spotify",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/spotify/src",
"projectType": "library",
"tags": [],
"targets": {
"lint": {
"executor": "@nx/eslint:lint",
"outputs": [
"{options.outputFile}"
]
},
"test": {
"executor": "@nx/jest:jest",
"outputs": [
"{workspaceRoot}/coverage/{projectRoot}"
],
"options": {
"jestConfig": "libs/spotify/jest.config.ts"
}
}
}
}
2 changes: 2 additions & 0 deletions backend/libs/spotify/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './lib/spotify.controller';
export * from './lib/spotify.module';
46 changes: 46 additions & 0 deletions backend/libs/spotify/src/lib/spotify.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SpotifyController } from './spotify.controller';
import { SpotifyService } from './spotify.service';

const mockTrack = {
id: 'track-id-1',
name: 'Test Song',
artist: 'Artist A',
album: 'Test Album',
albumCover: 'https://example.com/cover.jpg',
releaseYear: 1985,
spotifyUrl: 'https://open.spotify.com/track/track-id-1',
previewUrl: 'https://p.scdn.co/mp3-preview/abc123',
};

describe('SpotifyController', () => {
let controller: SpotifyController;
let service: SpotifyService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [SpotifyController],
providers: [
{
provide: SpotifyService,
useValue: {
getRandomTrack: jest.fn().mockResolvedValue(mockTrack),
},
},
],
}).compile();

controller = module.get<SpotifyController>(SpotifyController);
service = module.get<SpotifyService>(SpotifyService);
});

it('should be defined', () => {
expect(controller).toBeDefined();
});

it('should call getRandomTrack and return a track', async () => {
const result = await controller.getRandomTrack();
expect(service.getRandomTrack).toHaveBeenCalled();
expect(result).toEqual(mockTrack);
});
});
26 changes: 26 additions & 0 deletions backend/libs/spotify/src/lib/spotify.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { SpotifyTrackDto } from '@backend/shared-types';
import { Public } from '@backend/util';
import { Controller, Get } from '@nestjs/common';
import {
ApiBadRequestResponse,
ApiOkResponse,
ApiTags,
} from '@nestjs/swagger';
import { SpotifyService } from './spotify.service';

@ApiTags('spotify')
@Controller('/spotify')
export class SpotifyController {
constructor(private spotifyService: SpotifyService) {}

@Public()
@ApiOkResponse({
description: 'Random Spotify track for the Hitstar game.',
type: SpotifyTrackDto,
})
@ApiBadRequestResponse({ description: 'Bad or malformed request.' })
@Get('/random-track')
getRandomTrack() {
return this.spotifyService.getRandomTrack();
}
}
12 changes: 12 additions & 0 deletions backend/libs/spotify/src/lib/spotify.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { HttpModule } from '@nestjs/axios';
import { Module } from '@nestjs/common';
import { SpotifyController } from './spotify.controller';
import { SpotifyService } from './spotify.service';

@Module({
imports: [HttpModule],
controllers: [SpotifyController],
providers: [SpotifyService],
exports: [],
})
export class SpotifyModule {}
73 changes: 73 additions & 0 deletions backend/libs/spotify/src/lib/spotify.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { HttpService } from '@nestjs/axios';
import { Test, TestingModule } from '@nestjs/testing';
import { of } from 'rxjs';
import { SpotifyService } from './spotify.service';

const mockItunesResponse = {
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',
},
],
},
};

describe('SpotifyService', () => {
let service: SpotifyService;
let httpService: HttpService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SpotifyService,
{
provide: HttpService,
useValue: { get: jest.fn() },
},
],
}).compile();

service = module.get<SpotifyService>(SpotifyService);
httpService = module.get<HttpService>(HttpService);
});

it('should be defined', () => {
expect(service).toBeDefined();
});

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));

const result = await service.getRandomTrack();

expect(result.id).toBe('123456789');
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');
});

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

await expect(service.getRandomTrack()).rejects.toThrow(
'Could not find a random track after 10 attempts.',
);
});
});
63 changes: 63 additions & 0 deletions backend/libs/spotify/src/lib/spotify.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { SpotifyTrackDto } from '@backend/shared-types';
import { HttpService } from '@nestjs/axios';
import { Injectable, Logger } from '@nestjs/common';
import { firstValueFrom } from 'rxjs';

@Injectable()
export class SpotifyService {
private readonly logger = new Logger(SpotifyService.name);

constructor(private httpService: HttpService) {}

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

for (let attempt = 0; attempt < maxAttempts; attempt++) {
const char = String.fromCharCode(97 + Math.floor(Math.random() * 26));

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

try {
const searchUrl = `https://itunes.apple.com/search?term=${encodeURIComponent(char)}&entity=song&limit=20`;
const response = await firstValueFrom(this.httpService.get(searchUrl));

// 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;
});

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')
: '';

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

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

this.logger.warn(`Attempt ${attempt + 1}: no usable track found, retrying.`);
} catch (err) {
this.logger.warn(`Attempt ${attempt + 1} failed: ${err}`);
}
}

throw new Error(`Could not find a random track after ${maxAttempts} attempts.`);
}
}
22 changes: 22 additions & 0 deletions backend/libs/spotify/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
16 changes: 16 additions & 0 deletions backend/libs/spotify/tsconfig.lib.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"declaration": true,
"types": ["node"],
"target": "es2021",
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts"],
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]
}
Loading
Loading