Skip to content

Commit

Permalink
Random record selection
Browse files Browse the repository at this point in the history
  • Loading branch information
lrdiv committed Jan 11, 2025
1 parent cc4d094 commit 386ffa4
Show file tree
Hide file tree
Showing 15 changed files with 228 additions and 19 deletions.
5 changes: 2 additions & 3 deletions apps/spin-cycle-client/src/app/history/history.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { SpinOut } from '@spin-cycle-mono/shared';
import { Page } from '@spin-cycle-mono/shared';
import { Page, SpinOut } from '@spin-cycle-mono/shared';
import { Observable, map } from 'rxjs';

import { environment } from '../../environments/environment';
Expand All @@ -18,7 +17,7 @@ export class HistoryService {
return {
...page,
content: page.content.map((spin: SpinOut) => {
return new SpinOut(spin.artistName, spin.recordName, spin.createdAt, spin.played);
return new SpinOut(spin.id, spin.discogsId, spin.artistName, spin.recordName, spin.createdAt, spin.played);
}),
};
}),
Expand Down
2 changes: 1 addition & 1 deletion apps/spin-cycle/src/discogs/discogs.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export class DiscogsController {

@Get('/folders')
async getCollections(@Req() req: Request): Promise<FolderOut[]> {
const user = await this.userService.findById(req['user']?.sub);
const user = await this.userService.findById(req['user'].sub);
if (!user) {
throw new UnauthorizedException();
}
Expand Down
71 changes: 69 additions & 2 deletions apps/spin-cycle/src/discogs/discogs.service.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
import { Injectable } from '@nestjs/common';
import { FolderOut, UserEntity } from '@spin-cycle-mono/shared';
import {
FolderOut,
IDiscogsRelease,
IDiscogsReleases,
ReleaseOut,
SpinEntity,
UserEntity,
} from '@spin-cycle-mono/shared';
import OAuth from 'oauth-1.0a';

import { AllReleasesSpunException } from '../spins/spins.exception';
import { DiscogsAuthService } from './discogs-auth.service';

@Injectable()
export class DiscogsService {
private readonly client: OAuth;

constructor(private readonly oauthService: DiscogsAuthService) {
this.client = oauthService.client;
this.client = this.oauthService.client;
}

async getFolders(user: UserEntity): Promise<FolderOut[]> {
Expand All @@ -22,8 +30,67 @@ export class DiscogsService {
});
}

async getRandomRecord(user: UserEntity): Promise<ReleaseOut> {
const allRecords: ReleaseOut[] = await this.getAllRecordsFromFolder(user);
const playedSpins: SpinEntity[] = user.spins.filter((spin: SpinEntity) => spin.played);
return this.getUnplayedSpin(playedSpins, allRecords);
}

private getUnplayedSpin(spins: SpinEntity[], records: ReleaseOut[]): ReleaseOut {
if (!records.length) {
throw new AllReleasesSpunException();
}

const randomIndex: number = Math.floor(Math.random() * records.length);
const randomRelease: ReleaseOut = records[randomIndex];
const played: boolean = spins.some((spin: SpinEntity) => {
return spin.discogsId === randomRelease.discogsId;
});

if (!played) {
return randomRelease;
}

const remainder: ReleaseOut[] = records.filter((r: ReleaseOut) => r.discogsId !== randomRelease.discogsId);
return this.getUnplayedSpin(spins, remainder);
}

private async getAllRecordsFromFolder(user: UserEntity): Promise<ReleaseOut[]> {
const first: IDiscogsReleases = await this.getRecordsPageFromFolder(user, 1);
const firstPage: ReleaseOut[] = first.releases.map((r: IDiscogsRelease) => ReleaseOut.fromDiscogsResponse(r));
if (first.pagination.page === first.pagination.pages) {
return firstPage;
}

const remaining: number[] = this.getRange(2, first.pagination.pages + 1);
const promises: Array<IDiscogsReleases> = await Promise.all(
remaining.map((page: number) => {
return this.getRecordsPageFromFolder(user, page);
}),
);

return promises.reduce(
(all: ReleaseOut[], page: IDiscogsReleases) => {
const releases: ReleaseOut[] = page.releases.map((release) => ReleaseOut.fromDiscogsResponse(release));
return [...all, ...releases];
},
[...firstPage],
);
}

private async getRecordsPageFromFolder(user: UserEntity, page: number = 1): Promise<IDiscogsReleases> {
const { discogsUsername: username, discogsFolder: folder } = user;
const url = `https://api.discogs.com/users/${username}/collection/folders/${folder}/releases?page=${page}&per_page=100`;
const headers: OAuth.Header = this.getHeaders(url, user);
return fetch(url, { headers: { Authorization: headers.Authorization } }).then((res) => res.json());
}

private getHeaders(url: string, user: UserEntity, method = 'GET'): OAuth.Header {
const { discogsToken: key, discogsSecret: secret } = user;
return this.client.toHeader(this.client.authorize({ url, method }, { key, secret }));
}

private getRange(start: number, end: number, step: number = 1): number[] {
return Array.from({ length: Math.ceil((end - start) / step) }, (_, i) => start + i * step);
}
}
49 changes: 47 additions & 2 deletions apps/spin-cycle/src/spins/spins.controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,52 @@
import { Controller, UseGuards } from '@nestjs/common';
import { Controller, Get, Logger, Req, UnauthorizedException, UseGuards } from '@nestjs/common';
import { ReleaseOut, SpinEntity, SpinOut, UserEntity } from '@spin-cycle-mono/shared';
import { Request } from 'express';

import { AuthGuard } from '../auth/auth.guard';
import { DiscogsService } from '../discogs/discogs.service';
import { UserService } from '../users/user.service';
import { AllReleasesSpunException } from './spins.exception';
import { SpinsService } from './spins.service';

@Controller('/spins')
@UseGuards(AuthGuard)
export class SpinsController {}
export class SpinsController {
constructor(
private readonly discogsService: DiscogsService,
private readonly spinsService: SpinsService,
private readonly userService: UserService,
) {}

@Get('/random')
async getRandomRelease(@Req() req: Request): Promise<SpinOut> {
const user: UserEntity = await this.userService
.findByIdWithSpins(req['user'].sub)
.catch((e) => this.handleUnauthorized(e));

const nextSpin: ReleaseOut | null = await this.discogsService
.getRandomRecord(user)
.catch((e) => this.handleAllRecordsPlayed(e));

if (!nextSpin) {
return null;
}

const { discogsId, artistName, recordName } = nextSpin;
const releaseSpin: SpinEntity = new SpinEntity(null, user, discogsId, artistName, recordName, new Date());
const savedSpin: SpinEntity = await this.spinsService.create(releaseSpin);
return SpinOut.fromSpin(savedSpin);
}

private handleAllRecordsPlayed(e: unknown): null {
if (e instanceof AllReleasesSpunException) {
Logger.error(e);
return null;
}
throw e;
}

private handleUnauthorized(e: unknown): null {
Logger.error(e);
throw new UnauthorizedException();
}
}
1 change: 1 addition & 0 deletions apps/spin-cycle/src/spins/spins.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export class AllReleasesSpunException extends Error {}
7 changes: 6 additions & 1 deletion apps/spin-cycle/src/spins/spins.module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SpinEntity } from '@spin-cycle-mono/shared';

import { DiscogsModule } from '../discogs/discogs.module';
import { UserModule } from '../users/user.module';
import { SpinsController } from './spins.controller';
import { SpinsService } from './spins.service';

@Module({
controllers: [SpinsController],
imports: [UserModule, DiscogsModule],
providers: [SpinsService],
imports: [TypeOrmModule.forFeature([SpinEntity]), UserModule, DiscogsModule],
exports: [SpinsService],
})
export class SpinsModule {}
4 changes: 4 additions & 0 deletions apps/spin-cycle/src/spins/spins.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,8 @@ export class SpinsService {
@InjectRepository(SpinEntity)
private readonly spinRepository: Repository<SpinEntity>,
) {}

async create(spin: SpinEntity): Promise<SpinEntity> {
return this.spinRepository.save(spin);
}
}
4 changes: 4 additions & 0 deletions apps/spin-cycle/src/users/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ export class UserService {
return this.userRepository.findOne({ where: { discogsUsername: username } });
}

findByDiscogsUsernameWithSpins(username: string): Promise<UserEntity | null> {
return this.userRepository.findOne({ where: { discogsUsername: username }, relations: ['spins'] });
}

findByIdWithSpins(id: string): Promise<UserEntity | null> {
return this.userRepository.findOne({ where: { id }, relations: ['spins'] });
}
Expand Down
23 changes: 23 additions & 0 deletions libs/shared/src/lib/discogs-response.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export interface IDiscogsReleases extends IDiscogsResponse {
releases: IDiscogsRelease[];
}

export interface IDiscogsRelease {
id: number;
basic_information: {
title: string;
resource_url: string;
artists: Array<{ name: string }>;
};
}

interface IDiscogsResponse {
pagination: IDiscogsPagination;
}

interface IDiscogsPagination {
page: number;
pages: number;
per_page: number;
items: number;
}
24 changes: 18 additions & 6 deletions libs/shared/src/lib/dto/page.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
export class Page<T> {
readonly content: T[];
readonly page: number;
readonly totalItems: number;
readonly firstPage: boolean;
readonly lastPage: boolean;
// static fromDiscogsResponse<T>(discogsResponse: any[], responseKey: string): Page<T> {
// }

constructor(content: T[], page: number, totalItems: number, firstPage: boolean, lastPage: boolean) {
content: T[];
page: number;
totalItems: number;
totalPages: number;
firstPage: boolean;
lastPage: boolean;

constructor(
content: T[],
page: number,
totalItems: number,
totalPages: number,
firstPage: boolean,
lastPage: boolean,
) {
this.content = content;
this.page = page;
this.totalItems = totalItems;
this.totalPages = totalPages;
this.firstPage = firstPage;
this.lastPage = lastPage;
}
Expand Down
18 changes: 18 additions & 0 deletions libs/shared/src/lib/dto/release-out.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { IDiscogsRelease } from '../discogs-response';

export class ReleaseOut {
discogsId: number;
artistName: string;
recordName: string;

constructor(discogsId: number, artistName: string, recordName: string) {
this.discogsId = discogsId;
this.artistName = artistName;
this.recordName = recordName;
}

static fromDiscogsResponse(release: IDiscogsRelease): ReleaseOut {
const joinedArtists: string = release.basic_information.artists.map((artist) => artist.name).join(', ');
return new ReleaseOut(release.id, joinedArtists, release.basic_information.title);
}
}
12 changes: 11 additions & 1 deletion libs/shared/src/lib/dto/spin-out.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import { SpinEntity } from '../entities/spin.entity';

export class SpinOut {
id: number;
discogsId: number;
artistName: string;
recordName: string;
createdAt: Date;
played: boolean;

constructor(artistName: string, recordName: string, createdAt: Date, played: boolean) {
constructor(id: number, discogsId: number, artistName: string, recordName: string, createdAt: Date, played: boolean) {
this.id = id;
this.discogsId = discogsId;
this.artistName = artistName;
this.recordName = recordName;
this.createdAt = createdAt;
this.played = played;
}

static fromSpin(spin: SpinEntity): SpinOut {
return new SpinOut(spin.id, spin.discogsId, spin.artistName, spin.recordName, spin.createdAt, spin.played);
}
}
20 changes: 18 additions & 2 deletions libs/shared/src/lib/entities/spin.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,39 @@ import { UserEntity } from './user.entity';

@Entity('spins')
export class SpinEntity {
constructor(id: number, artistName: string, recordName: string) {
constructor(
id: number,
user: UserEntity,
discogsId: number,
artistName: string,
recordName: string,
createdAt: Date,
) {
this.id = id;
this.user = user;
this.discogsId = discogsId;
this.artistName = artistName;
this.recordName = recordName;
this.createdAt = createdAt;
}

@PrimaryGeneratedColumn()
id: number;

@Column()
discogsId: number;

@Column()
artistName: string;

@Column()
recordName: string;

@Column()
played: boolean = true;

@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
createdAt: Date | null = null;
createdAt: Date;

@ManyToOne(() => UserEntity, (user) => user.spins, { onDelete: 'CASCADE' })
user!: UserEntity;
Expand Down
4 changes: 3 additions & 1 deletion libs/shared/src/lib/entities/user.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ export class UserEntity {
discogsId: number,
discogsToken: string,
discogsSecret: string,
spins?: SpinEntity[],
) {
this.id = id;
this.email = email;
this.discogsId = discogsId;
this.discogsUsername = discogsUsername;
this.discogsToken = discogsToken;
this.discogsSecret = discogsSecret;
this.spins = spins ?? [];
}

@PrimaryGeneratedColumn('uuid')
Expand Down Expand Up @@ -51,5 +53,5 @@ export class UserEntity {
updatedAt: Date | null = null;

@OneToMany(() => SpinEntity, (spin) => spin.user, { cascade: false })
spins!: SpinEntity[];
spins: SpinEntity[];
}
3 changes: 3 additions & 0 deletions libs/shared/src/lib/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ export * from './dto/token-out';
export * from './dto/user-out';
export * from './dto/spin-out';
export * from './dto/folder-out';
export * from './dto/release-out';
export * from './dto/page';

export * from './entities/spin.entity';
export * from './entities/user.entity';

export * from './discogs-response';

0 comments on commit 386ffa4

Please sign in to comment.