From 52f5b8943738883ef1d09e1bb973dca2ce8876e2 Mon Sep 17 00:00:00 2001 From: Rab Rennie Date: Sun, 3 Mar 2024 18:14:39 +0000 Subject: [PATCH] Movies (#28) * Add type to room * Change room type default * Add type to room creation modal * Allow user to select movies --- .env.example | 3 + .../migration.sql | 58 ++++++++++++++++ .../migration.sql | 58 ++++++++++++++++ prisma/schema.prisma | 1 + .../SelectedAlbum/SelectedAlbum.svelte | 8 ++- src/lib/server/omdb.ts | 37 +++++++++++ src/routes/api/movie/+server.ts | 20 ++++++ src/routes/api/search/+server.ts | 38 ++++++++--- src/routes/room/[room]/+page.server.ts | 66 ++++++++++++++----- src/routes/room/[room]/+page.svelte | 16 +++-- src/routes/room/[room]/MovieInfo.svelte | 50 ++++++++++++++ src/routes/room/[room]/SearchModal.svelte | 48 ++++++++++---- src/routes/team/[id]/+page.server.ts | 6 +- src/routes/team/[id]/+page.svelte | 10 ++- src/routes/team/[id]/CreateRoomModal.svelte | 5 ++ src/types/Room.ts | 1 + 16 files changed, 378 insertions(+), 47 deletions(-) create mode 100644 prisma/migrations/20240303163024_add_type_to_room/migration.sql create mode 100644 prisma/migrations/20240303163500_change_room_type_default_to_albums/migration.sql create mode 100644 src/lib/server/omdb.ts create mode 100644 src/routes/api/movie/+server.ts create mode 100644 src/routes/room/[room]/MovieInfo.svelte diff --git a/.env.example b/.env.example index 26a5ca6..273ef88 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,6 @@ GOOGLE_SECRET="" # Spotify credentials for album search SPOTIFY_CLIENT_ID="" SPOTIFY_CLIENT_SECRET="" + +# OMDB API key for movie search +OMDB_API_KEY="" diff --git a/prisma/migrations/20240303163024_add_type_to_room/migration.sql b/prisma/migrations/20240303163024_add_type_to_room/migration.sql new file mode 100644 index 0000000..e39bbf6 --- /dev/null +++ b/prisma/migrations/20240303163024_add_type_to_room/migration.sql @@ -0,0 +1,58 @@ +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Choice" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "albumId" TEXT NOT NULL, + "albumImage" TEXT NOT NULL, + "albumArtist" TEXT NOT NULL, + "albumName" TEXT NOT NULL, + "eliminated" BOOLEAN NOT NULL, + "cssGradient" TEXT, + "createdAt" INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + "roomId" INTEGER NOT NULL, + "userId" INTEGER NOT NULL, + CONSTRAINT "Choice_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "Choice_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_Choice" ("albumArtist", "albumId", "albumImage", "albumName", "createdAt", "cssGradient", "eliminated", "id", "roomId", "userId") SELECT "albumArtist", "albumId", "albumImage", "albumName", "createdAt", "cssGradient", "eliminated", "id", "roomId", "userId" FROM "Choice"; +DROP TABLE "Choice"; +ALTER TABLE "new_Choice" RENAME TO "Choice"; +CREATE UNIQUE INDEX "Choice_roomId_userId_key" ON "Choice"("roomId", "userId"); +CREATE TABLE "new_Room" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "linkId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "step" TEXT NOT NULL DEFAULT 'selecting', + "createdAt" INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + "type" TEXT NOT NULL DEFAULT 'albums', + "teamId" INTEGER NOT NULL, + CONSTRAINT "Room_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_Room" ("createdAt", "id", "linkId", "name", "step", "teamId") SELECT "createdAt", "id", "linkId", "name", "step", "teamId" FROM "Room"; +DROP TABLE "Room"; +ALTER TABLE "new_Room" RENAME TO "Room"; +CREATE UNIQUE INDEX "Room_linkId_key" ON "Room"("linkId"); +CREATE TABLE "new_User" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "email" TEXT NOT NULL, + "name" TEXT NOT NULL, + "image" TEXT, + "provider" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "createdAt" INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) +); +INSERT INTO "new_User" ("createdAt", "email", "id", "image", "name", "provider", "providerId") SELECT "createdAt", "email", "id", "image", "name", "provider", "providerId" FROM "User"; +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; +CREATE UNIQUE INDEX "User_provider_providerId_key" ON "User"("provider", "providerId"); +CREATE TABLE "new_Team" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "name" TEXT NOT NULL, + "createdAt" INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + "invite" TEXT NOT NULL +); +INSERT INTO "new_Team" ("createdAt", "id", "invite", "name") SELECT "createdAt", "id", "invite", "name" FROM "Team"; +DROP TABLE "Team"; +ALTER TABLE "new_Team" RENAME TO "Team"; +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/prisma/migrations/20240303163500_change_room_type_default_to_albums/migration.sql b/prisma/migrations/20240303163500_change_room_type_default_to_albums/migration.sql new file mode 100644 index 0000000..acc8b97 --- /dev/null +++ b/prisma/migrations/20240303163500_change_room_type_default_to_albums/migration.sql @@ -0,0 +1,58 @@ +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Room" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "linkId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "step" TEXT NOT NULL DEFAULT 'selecting', + "createdAt" INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + "type" TEXT NOT NULL DEFAULT 'albums', + "teamId" INTEGER NOT NULL, + CONSTRAINT "Room_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_Room" ("createdAt", "id", "linkId", "name", "step", "teamId", "type") SELECT "createdAt", "id", "linkId", "name", "step", "teamId", "type" FROM "Room"; +DROP TABLE "Room"; +ALTER TABLE "new_Room" RENAME TO "Room"; +CREATE UNIQUE INDEX "Room_linkId_key" ON "Room"("linkId"); +CREATE TABLE "new_User" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "email" TEXT NOT NULL, + "name" TEXT NOT NULL, + "image" TEXT, + "provider" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "createdAt" INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) +); +INSERT INTO "new_User" ("createdAt", "email", "id", "image", "name", "provider", "providerId") SELECT "createdAt", "email", "id", "image", "name", "provider", "providerId" FROM "User"; +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; +CREATE UNIQUE INDEX "User_provider_providerId_key" ON "User"("provider", "providerId"); +CREATE TABLE "new_Choice" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "albumId" TEXT NOT NULL, + "albumImage" TEXT NOT NULL, + "albumArtist" TEXT NOT NULL, + "albumName" TEXT NOT NULL, + "eliminated" BOOLEAN NOT NULL, + "cssGradient" TEXT, + "createdAt" INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + "roomId" INTEGER NOT NULL, + "userId" INTEGER NOT NULL, + CONSTRAINT "Choice_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "Choice_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_Choice" ("albumArtist", "albumId", "albumImage", "albumName", "createdAt", "cssGradient", "eliminated", "id", "roomId", "userId") SELECT "albumArtist", "albumId", "albumImage", "albumName", "createdAt", "cssGradient", "eliminated", "id", "roomId", "userId" FROM "Choice"; +DROP TABLE "Choice"; +ALTER TABLE "new_Choice" RENAME TO "Choice"; +CREATE UNIQUE INDEX "Choice_roomId_userId_key" ON "Choice"("roomId", "userId"); +CREATE TABLE "new_Team" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "name" TEXT NOT NULL, + "createdAt" INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + "invite" TEXT NOT NULL +); +INSERT INTO "new_Team" ("createdAt", "id", "invite", "name") SELECT "createdAt", "id", "invite", "name" FROM "Team"; +DROP TABLE "Team"; +ALTER TABLE "new_Team" RENAME TO "Team"; +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 19ff825..83f7d86 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -58,6 +58,7 @@ model Room { name String step String @default("selecting") createdAt Int @default(dbgenerated("(strftime('%s', 'now'))")) + type String @default("albums") choices Choice[] diff --git a/src/lib/components/SelectedAlbum/SelectedAlbum.svelte b/src/lib/components/SelectedAlbum/SelectedAlbum.svelte index 97f5721..0a30487 100644 --- a/src/lib/components/SelectedAlbum/SelectedAlbum.svelte +++ b/src/lib/components/SelectedAlbum/SelectedAlbum.svelte @@ -10,6 +10,7 @@ export let avatar = ''; export let small = false; export let winner = false; + export let type: 'albums' | 'movies' = 'albums'; let imageLoaded = false; @@ -24,7 +25,8 @@ } $: height = small ? 'h-[4.5rem]' : 'h-auto sm:h-40'; - $: width = small ? 'w-[4.5rem]' : 'w-full sm:w-40'; + $: width = 'auto'; + $: aspect = type === 'albums' ? 'aspect-square' : 'aspect-[2/3]';
{/if} {#if imageLoaded}
diff --git a/src/lib/server/omdb.ts b/src/lib/server/omdb.ts new file mode 100644 index 0000000..289dfdd --- /dev/null +++ b/src/lib/server/omdb.ts @@ -0,0 +1,37 @@ +import { env } from '$env/dynamic/private'; + +interface OmdbMovie { + Title: string; + Year: string; + imdbID: string; + Type: string; + Poster: string; +} + +interface OmdbSearchResponse { + Search: OmdbMovie[]; + totalResults: string; + Response: string; +} + +export const getToken = async () => { + return env.OMDB_API_KEY; +}; + +export const search = async (query: string, token: string): Promise => { + const params = new URLSearchParams({ s: query, type: 'movie', apikey: token }); + const res = await fetch(`https://www.omdbapi.com/?${params}`, { + method: 'GET' + }); + + return await res.json(); +}; + +export const singleMovie = async (movieId: string, token: string): Promise => { + const params = new URLSearchParams({ i: movieId, apikey: token }); + const res = await fetch(`https://www.omdbapi.com/?${params}`, { + method: 'GET' + }); + + return await res.json(); +}; diff --git a/src/routes/api/movie/+server.ts b/src/routes/api/movie/+server.ts new file mode 100644 index 0000000..66adf41 --- /dev/null +++ b/src/routes/api/movie/+server.ts @@ -0,0 +1,20 @@ +import * as OMDb from '$lib/server/omdb'; + +import type { RequestHandler } from './$types'; + +export const GET = (async ({ url }) => { + const id = String(url.searchParams.get('id')); + + if (!id) { + return new Response('No id', { status: 400 }); + } + + const token = await OMDb.getToken(); + const result = await OMDb.singleMovie(id, token); + + return new Response(JSON.stringify(result), { + headers: { + 'Content-Type': 'application/json' + } + }); +}) satisfies RequestHandler; diff --git a/src/routes/api/search/+server.ts b/src/routes/api/search/+server.ts index fc7362c..1c95f2c 100644 --- a/src/routes/api/search/+server.ts +++ b/src/routes/api/search/+server.ts @@ -1,15 +1,37 @@ -import { getToken, search } from '$lib/server/spotify'; +import * as Spotify from '$lib/server/spotify'; +import * as OMDb from '$lib/server/omdb'; + import type { RequestHandler } from './$types'; export const GET = (async ({ url }) => { const query = String(url.searchParams.get('q')); + const type = String(url.searchParams.get('type')); + + if (!query) { + return new Response('No query', { status: 400 }); + } else if (!type) { + return new Response('No type', { status: 400 }); + } + + if (type === 'albums') { + const token = await Spotify.getToken(); + const results = await Spotify.search(query, token); + + return new Response(JSON.stringify(results.albums.items), { + headers: { + 'Content-Type': 'application/json' + } + }); + } else if (type === 'movies') { + const token = await OMDb.getToken(); + const results = await OMDb.search(query, token); - const token = await getToken(); - const results = await search(query, token); + return new Response(JSON.stringify(results.Search), { + headers: { + 'Content-Type': 'application/json' + } + }); + } - return new Response(JSON.stringify(results.albums.items), { - headers: { - 'Content-Type': 'application/json' - } - }); + return new Response('Invalid type', { status: 400 }); }) satisfies RequestHandler; diff --git a/src/routes/room/[room]/+page.server.ts b/src/routes/room/[room]/+page.server.ts index eb59b23..a39b14b 100644 --- a/src/routes/room/[room]/+page.server.ts +++ b/src/routes/room/[room]/+page.server.ts @@ -9,7 +9,8 @@ import { RoomState, type Choice as RoomChoice } from '../../../types/Room'; import { RequireAuth } from '@rabrennie/sveltekit-auth/helpers'; import type { Choice, User } from '@prisma/client'; import crypto from 'crypto'; -import { getToken, singleAlbum } from '$lib/server/spotify'; +import * as Spotify from '$lib/server/spotify'; +import * as OMDb from '$lib/server/omdb'; import { getPlaiceholder } from 'plaiceholder'; const getLinkId = (event: RequestEvent) => { @@ -17,14 +18,17 @@ const getLinkId = (event: RequestEvent) => { return linkId; }; -const mapChoice = (choice: Choice): RoomChoice => ({ +const mapChoice = (choice: Choice, type: 'albums' | 'movies'): RoomChoice => ({ userId: choice.userId, eliminated: choice.eliminated, choice: { artist: choice.albumArtist, title: choice.albumName, imageUrl: choice.albumImage, - url: `https://open.spotify.com/album/${choice.albumId}`, + url: + type === 'albums' + ? `https://open.spotify.com/album/${choice.albumId}` + : `https://www.imdb.com/title/${choice.albumId}`, cssGradient: choice.cssGradient ?? '' } }); @@ -43,9 +47,12 @@ export const load = (async (event) => { id: dbRoom.id, teamId: dbRoom.team.id, name: dbRoom.name, + type: dbRoom.type, state: dbRoom.step as RoomState, users: dbRoom.team.users.map((u) => ({ id: u.id, name: u.name, image: u.image })), - choices: Object.fromEntries(dbRoom.choices.map((c) => [c.userId, mapChoice(c)])) + choices: Object.fromEntries( + dbRoom.choices.map((c) => [c.userId, mapChoice(c, dbRoom.type)]) + ) }; return { @@ -77,18 +84,45 @@ export const actions = { return result.response; } - const token = await getToken(); - const album = await singleAlbum(result.data.id, token); - const src = album?.images?.at(-1)?.url; + let albumId: string | undefined; + let albumArtist: string | undefined; + let albumImage: string | undefined; + let albumName: string | undefined; + let cssGradient: string | null = null; + + if (dbRoom.type === 'albums') { + const token = await Spotify.getToken(); + const album = await Spotify.singleAlbum(result.data.id, token); + const src = album?.images?.at(-1)?.url; + + if (!album || !src) { + return fail(400, { error: 'Album does not exist' }); + } + + albumId = album.id; + albumArtist = album.artists[0].name; + albumImage = src; + albumName = album.name; + } else if (dbRoom.type === 'movies') { + const token = await OMDb.getToken(); + const movie = await OMDb.singleMovie(result.data.id, token); - if (!album || !src) { - return fail(400, { error: 'Album does not exist' }); + if (!movie || movie.Poster === 'N/A') { + return fail(400, { error: 'Movie does not exist' }); + } + + albumId = movie.imdbID; + albumArtist = movie.Year; + albumImage = movie.Poster; + albumName = movie.Title; } - let cssGradient: string | null = null; + if (!albumId || !albumArtist || !albumImage || !albumName) { + return fail(400, { error: 'Missing data' }); + } try { - const buffer = await fetch(src).then(async (res) => + const buffer = await fetch(albumImage).then(async (res) => Buffer.from(await res.arrayBuffer()) ); @@ -107,10 +141,10 @@ export const actions = { }; const albumData = { - albumId: album.id, - albumArtist: album.artists[0].name, - albumImage: album.images[0].url, - albumName: album.name, + albumId, + albumArtist, + albumImage, + albumName, eliminated: false, cssGradient }; @@ -125,7 +159,7 @@ export const actions = { }); roomsState.broadcast(dbRoom.linkId, 'room:choices:update', { - choices: [mapChoice(dbChoice)] + choices: [mapChoice(dbChoice, dbRoom.type)] }); }), nextStep: RequireAuth(async (event) => { diff --git a/src/routes/room/[room]/+page.svelte b/src/routes/room/[room]/+page.svelte index a88f700..b4b7089 100644 --- a/src/routes/room/[room]/+page.svelte +++ b/src/routes/room/[room]/+page.svelte @@ -19,6 +19,7 @@ import type User from '../../../types/User'; import Confetti from '$lib/components/Confetti/Confetti.svelte'; import type RoomEventSource from '$lib/room/RoomEventSource'; + import MovieInfo from './MovieInfo.svelte'; const [send, receive] = crossfade({ duration: 300, @@ -141,7 +142,9 @@
{#if userChoices.length === 0} -
Nobody has selected an album yet 😢
+
+ Nobody has selected a{$roomStore.type === 'albums' ? 'n album' : ' movie'} yet 😢 +
{/if} {#if !eliminating}
@@ -162,11 +165,12 @@ cssGradient={choice.cssGradient} avatar={user.image ?? ''} winner={$roomStore.state === RoomState.FINISHED} + type={$roomStore.type} />
{/each} - {#if $roomStore.state === RoomState.FINISHED && winner} + {#if $roomStore.state === RoomState.FINISHED && winner && $roomStore.type === 'albums'}