Skip to content

Commit

Permalink
feat(playlist): implement playlist feature (#24)
Browse files Browse the repository at this point in the history
  • Loading branch information
async3619 authored Sep 14, 2023
1 parent 7aea1b2 commit b300d96
Show file tree
Hide file tree
Showing 76 changed files with 3,584 additions and 120 deletions.
20 changes: 16 additions & 4 deletions apps/main/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,33 +16,44 @@ import { AlbumModule } from "@album/album.module";
import { ArtistModule } from "@artist/artist.module";
import { ImageModule } from "@image/image.module";
import { AlbumArtModule } from "@album-art/album-art.module";
import { PlaylistModule } from "@playlist/playlist.module";

import { AlbumService } from "@album/album.service";
import { AlbumArtService } from "@album-art/album-art.service";
import { ArtistService } from "@artist/artist.service";
import { ImageService } from "@image/image.service";
import { MusicService } from "@music/music.service";

import { createGraphQLContext } from "@root/context";

@Module({
imports: [
GraphQLModule.forRootAsync<ElectronApolloDriverConfig>({
imports: [AlbumModule, AlbumArtModule, ArtistModule, ImageModule],
inject: [AlbumService, AlbumArtService, ArtistService, ImageService],
imports: [AlbumModule, AlbumArtModule, ArtistModule, ImageModule, MusicModule],
inject: [AlbumService, AlbumArtService, ArtistService, ImageService, MusicService],
driver: ElectronApolloDriver,
useFactory: (
albumService: AlbumService,
albumArtService: AlbumArtService,
artistService: ArtistService,
imageService: ImageService,
musicService: MusicService,
) => ({
installSubscriptionHandlers: true,
autoSchemaFile:
process.env.NODE_ENV === "production"
? true
: path.join(process.cwd(), "..", "..", "schema.graphql"),
context: window =>
createGraphQLContext(window, albumService, albumArtService, artistService, imageService),
context: window => {
return createGraphQLContext(
window,
albumService,
albumArtService,
artistService,
imageService,
musicService,
);
},
}),
}),
TypeOrmModule.forRoot({
Expand All @@ -61,6 +72,7 @@ import { createGraphQLContext } from "@root/context";
ArtistModule,
ImageModule,
AlbumArtModule,
PlaylistModule,
],
})
export class AppModule {}
5 changes: 5 additions & 0 deletions apps/main/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ import { Album } from "@album/models/album.model";
import { AlbumArt } from "@album-art/models/album-art.model";
import { Artist } from "@artist/models/artist.model";
import { Image } from "@image/models/image.model";
import { Music } from "@music/models/music.model";

import { AlbumService } from "@album/album.service";
import { AlbumArtService } from "@album-art/album-art.service";
import { ArtistService } from "@artist/artist.service";
import { ImageService } from "@image/image.service";
import { MusicService } from "@music/music.service";

export interface GraphQLContext extends BaseContext {
window: BrowserWindow | null;
Expand All @@ -21,6 +23,7 @@ export interface GraphQLContext extends BaseContext {
albumArt: DataLoader<number, AlbumArt>;
artist: DataLoader<number, Artist>;
image: DataLoader<number, Image>;
music: DataLoader<number, Music>;
primaryAlbumArt: DataLoader<number[], AlbumArt | null, string>;
};
}
Expand All @@ -31,6 +34,7 @@ export async function createGraphQLContext(
albumArtService: AlbumArtService,
artistService: ArtistService,
imageService: ImageService,
musicService: MusicService,
): Promise<GraphQLContext> {
return {
window,
Expand All @@ -39,6 +43,7 @@ export async function createGraphQLContext(
albumArt: new DataLoader<number, AlbumArt>(ids => albumArtService.findByIds(ids)),
artist: new DataLoader<number, Artist>(ids => artistService.findByIds(ids)),
image: new DataLoader<number, Image>(ids => imageService.findByIds(ids)),
music: new DataLoader<number, Music>(ids => musicService.findByIds(ids)),
primaryAlbumArt: new DataLoader<number[], AlbumArt | null, string>(
async idChunks => {
const allItems = await albumArtService.findAll();
Expand Down
26 changes: 26 additions & 0 deletions apps/main/src/playlist/models/playlist.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Entity, BaseEntity, PrimaryGeneratedColumn, Column, CreateDateColumn } from "typeorm";
import { Field, ObjectType, Int } from "@nestjs/graphql";

@Entity({ name: "playlists" })
@ObjectType()
export class Playlist extends BaseEntity {
@Field(() => Int)
@PrimaryGeneratedColumn()
public id!: number;

@Field(() => String)
@Column({ type: "varchar", length: 255 })
public name!: string;

@Field(() => [Int])
@Column({ type: "simple-array" })
public musicIds!: number[];

@Field(() => Date)
@CreateDateColumn()
public createdAt!: Date;

@Field(() => Date)
@CreateDateColumn()
public updatedAt!: Date;
}
16 changes: 16 additions & 0 deletions apps/main/src/playlist/playlist.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";

import { PlaylistService } from "@playlist/playlist.service";
import { PlaylistResolver } from "@playlist/playlist.resolver";

import { MusicModule } from "@music/music.module";

import { Playlist } from "@playlist/models/playlist.model";

@Module({
imports: [TypeOrmModule.forFeature([Playlist]), MusicModule],
providers: [PlaylistService, PlaylistResolver],
exports: [PlaylistService],
})
export class PlaylistModule {}
94 changes: 94 additions & 0 deletions apps/main/src/playlist/playlist.resolver.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { Test, TestingModule } from "@nestjs/testing";
import { PlaylistResolver } from "@playlist/playlist.resolver";
import { PlaylistEvents, PlaylistService } from "@playlist/playlist.service";

describe("PlaylistResolver", () => {
let resolver: PlaylistResolver;
let service: Record<string, jest.Mock>;

beforeEach(async () => {
service = {
findById: jest.fn(),
findAll: jest.fn(),
createFromMusicIds: jest.fn(),
addMusicsToPlaylist: jest.fn(),
asyncIterator: jest.fn(),
delete: jest.fn(),
clear: jest.fn(),
rename: jest.fn(),
deleteItems: jest.fn(),
};

const module: TestingModule = await Test.createTestingModule({
providers: [PlaylistResolver, { provide: PlaylistService, useValue: service }],
}).compile();

resolver = module.get<PlaylistResolver>(PlaylistResolver);
});

it("should be defined", () => {
expect(resolver).toBeDefined();
});

it("should be able to find a playlist with given id", async () => {
await resolver.playlist(1);
expect(service.findById).toHaveBeenCalled();
});

it("should be able to find all playlists", async () => {
await resolver.playlists();
expect(service.findAll).toHaveBeenCalled();
});

it("should be able to create a playlist", async () => {
await resolver.createPlaylist("name", [1, 2, 3]);
expect(service.createFromMusicIds).toHaveBeenCalled();
});

it("should be able to add musics to a playlist", async () => {
await resolver.addMusicsToPlaylist(1, [1, 2, 3]);
expect(service.addMusicsToPlaylist).toHaveBeenCalled();
});

it("should be able to clear a playlist", async () => {
await resolver.clearPlaylist(1);
expect(service.clear).toHaveBeenCalled();
});

it("should be able to rename a playlist", async () => {
await resolver.renamePlaylist(1, "name");
expect(service.rename).toHaveBeenCalled();
});

it("should be able to delete a playlist", async () => {
await resolver.deletePlaylist(1);
expect(service.delete).toHaveBeenCalled();
});

it("should be able to delete items from a playlist", async () => {
await resolver.deletePlaylistItems(1, [1, 2, 3]);
expect(service.deleteItems).toHaveBeenCalledWith(1, [1, 2, 3]);
});

it("should be able to resolve musics", async () => {
const musicLoader = { load: jest.fn() };
await resolver.musics({ musicIds: [1, 2, 3] } as any, { music: musicLoader } as any);

expect(musicLoader.load).toHaveBeenCalledTimes(3);
});

it("should be able to subscribe to playlistCreated event", async () => {
await resolver.playlistCreated();
expect(service.asyncIterator).toHaveBeenCalledWith(PlaylistEvents.CREATED);
});

it("should be able to subscribe to playlistDeleted event", async () => {
await resolver.playlistDeleted();
expect(service.asyncIterator).toHaveBeenCalledWith(PlaylistEvents.DELETED);
});

it("should be able to subscribe to playlistUpdated event", async () => {
await resolver.playlistUpdated();
expect(service.asyncIterator).toHaveBeenCalledWith(PlaylistEvents.UPDATED);
});
});
93 changes: 93 additions & 0 deletions apps/main/src/playlist/playlist.resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { Inject } from "@nestjs/common";
import { Args, Context, Int, Mutation, Parent, Query, ResolveField, Resolver, Subscription } from "@nestjs/graphql";

import { PlaylistEvents, PlaylistService } from "@playlist/playlist.service";
import { Playlist } from "@playlist/models/playlist.model";
import { Music } from "@music/models/music.model";

import { GraphQLContext } from "@root/context";

@Resolver(() => Playlist)
export class PlaylistResolver {
public constructor(@Inject(PlaylistService) private readonly playlistService: PlaylistService) {}

@Query(() => Playlist, { nullable: true })
public async playlist(@Args("id", { type: () => Int }) id: number) {
return this.playlistService.findById(id);
}

@Query(() => [Playlist])
public async playlists(): Promise<Playlist[]> {
return this.playlistService.findAll();
}

@Mutation(() => Playlist)
public async createPlaylist(
@Args("name", { type: () => String }) name: string,
@Args("musicIds", { type: () => [Int] }) musicIds: number[],
): Promise<Playlist> {
return this.playlistService.createFromMusicIds(name, musicIds);
}

@Mutation(() => Boolean)
public async clearPlaylist(@Args("playlistId", { type: () => Int }) playlistId: number): Promise<boolean> {
await this.playlistService.clear(playlistId);
return true;
}

@Mutation(() => Boolean)
public async deletePlaylist(@Args("id", { type: () => Int }) id: number): Promise<boolean> {
await this.playlistService.delete(id);
return true;
}

@Mutation(() => Boolean)
public async deletePlaylistItems(
@Args("playlistId", { type: () => Int }) playlistId: number,
@Args("indices", { type: () => [Int] }) indices: number[],
): Promise<boolean> {
await this.playlistService.deleteItems(playlistId, indices);
return true;
}

@Mutation(() => Boolean)
public async renamePlaylist(
@Args("id", { type: () => Int }) id: number,
@Args("name", { type: () => String }) name: string,
): Promise<boolean> {
await this.playlistService.rename(id, name);
return true;
}

@Mutation(() => Boolean)
public async addMusicsToPlaylist(
@Args("playlistId", { type: () => Int }) playlistId: number,
@Args("musicIds", { type: () => [Int] }) musicIds: number[],
): Promise<boolean> {
await this.playlistService.addMusicsToPlaylist(playlistId, musicIds);
return true;
}

@ResolveField(() => [Music])
public async musics(
@Parent() playlist: Playlist,
@Context("loaders") loaders: GraphQLContext["loaders"],
): Promise<Music[]> {
return Promise.all(playlist.musicIds.map(id => loaders.music.load(id)));
}

@Subscription(() => Playlist, { resolve: payload => payload[PlaylistEvents.CREATED] })
public playlistCreated() {
return this.playlistService.asyncIterator(PlaylistEvents.CREATED);
}

@Subscription(() => Int, { resolve: payload => payload[PlaylistEvents.DELETED] })
public playlistDeleted() {
return this.playlistService.asyncIterator(PlaylistEvents.DELETED);
}

@Subscription(() => Playlist, { resolve: payload => payload[PlaylistEvents.UPDATED] })
public playlistUpdated() {
return this.playlistService.asyncIterator(PlaylistEvents.UPDATED);
}
}
Loading

0 comments on commit b300d96

Please sign in to comment.