@ -0,0 +1,149 @@ | |||||
<template> | |||||
<div class="modal p-8 overflow-scroll items-center"> | |||||
<div class="w-full max-w-6xl bg-white overflow-hidden rounded-xl flex-col flex-shrink-0 shadow-xl"> | |||||
<div class="bg-center bg-cover" :class="{ 'text-white': isDark }" | |||||
:style="`background-image: url('/api/tmdb/image/w1280${movie.backdrop_path}')`"> | |||||
<div :style="backdropOverlayStyle" class="flex"> | |||||
<div class="flex p-4"> | |||||
<div class="w-24 h-40 flex-shrink-0 md:h-auto md:w-72 rounded-lg md:rounded-xl overflow-hidden shadow-md mdshadow-xl"> | |||||
<i v-if="!movie.poster_path" class="fas fa-image mx-auto my-auto"></i> | |||||
<img v-else class="w-full h-full object-cover" | |||||
:src="`/api/tmdb/image/w342${movie.poster_path}`"> | |||||
</div> | |||||
<div class="p-4 flex flex-col justify-center"> | |||||
<h2 class="text-lg md:text-4xl"> | |||||
<span class="font-bold">{{ movie.title }}</span> | |||||
<span class="opacity-70"> ({{ releaseYear }})</span> | |||||
</h2> | |||||
<div class="flex font-light dot-separated"> | |||||
<span>{{ releaseDate }}</span> | |||||
<span>{{ runtime }}</span> | |||||
</div> | |||||
<div v-if="movieDetails?.overview" class="mt-4 hidden md:block"> | |||||
<p class="">{{movieDetails?.overview}}</p> | |||||
</div> | |||||
<div class="mt-4 hidden md:block"> | |||||
<button class="py-2 px-8 rounded-full" :class="{ 'bg-white text-black': isDark, 'bg-black text-white': !isDark }">Request</button> | |||||
</div> | |||||
</div> | |||||
</div> | |||||
</div> | |||||
</div> | |||||
<div class="p-4 space-y-4 md:hidden"> | |||||
<div v-if="movieDetails?.overview"> | |||||
<h3 class="font-bold">Overview</h3> | |||||
<p>{{ movieDetails?.overview }}</p> | |||||
</div> | |||||
<div class="text-center"> | |||||
<button class="py-2 px-8 rounded-full bg-red-500 text-white">Request</button> | |||||
</div> | |||||
</div> | |||||
</div> | |||||
</div> | |||||
</template> | |||||
<script lang="ts"> | |||||
import { defineComponent } from "vue"; | |||||
import { IApiDataResponse } from "@common/api_schema"; | |||||
import { IMovieDetails } from "@lib/tmdb/schema"; | |||||
import { authFetch } from "../auth"; | |||||
import { getAverageRgb } from "../util"; | |||||
type MovieDetails = IApiDataResponse<IMovieDetails>; | |||||
export default defineComponent({ | |||||
computed: { | |||||
averageRgb(): { r: number, g: number, b: number } { | |||||
let rgb = getAverageRgb(this.imgElem); | |||||
// Adjust contrast | |||||
let c = 60; | |||||
let f = 259*(c + 255) / (255*(259 - c)); | |||||
for (let k in rgb) { | |||||
rgb[k] = Math.min(Math.max(f*(rgb[k] - 128) + 128, 0), 255); | |||||
} | |||||
// Adjust brightness | |||||
for (let k in rgb) { | |||||
rgb[k] *= 0.90; | |||||
} | |||||
return rgb; | |||||
}, | |||||
backdropOverlayStyle(): string { | |||||
let { r, g, b } = this.averageRgb; | |||||
return `background: linear-gradient(to right, rgba(${r}, ${g}, ${b}, 1.0), rgba(${r}, ${g}, ${b}, 0.84)`; | |||||
}, | |||||
isDark(): boolean { | |||||
let { r, g, b } = this.averageRgb; | |||||
return (r*0.299 + g*0.587 + b*0.114) <= 186; | |||||
// r /= 255; | |||||
// g /= 255; | |||||
// b /= 255; | |||||
// r = r <= 0.03928 ? r/12.92 : Math.pow((r+0.055)/1.055, 2.4); | |||||
// g = g <= 0.03928 ? r/12.92 : Math.pow((r+0.055)/1.055, 2.4); | |||||
// b = b <= 0.03928 ? r/12.92 : Math.pow((r+0.055)/1.055, 2.4); | |||||
// let L = 0.2126*r + 0.7152*g + 0.0722*b; | |||||
// return L <= 0.179; | |||||
}, | |||||
releaseYear(): string { | |||||
if (!this.movieDetails || !this.movieDetails.release_date) { | |||||
return ""; | |||||
} | |||||
return this.movieDetails.release_date.slice(0, 4); | |||||
}, | |||||
releaseDate(): string { | |||||
if (!this.movieDetails || !this.movieDetails.release_date) { | |||||
return ""; | |||||
} | |||||
let [year, month, day] = this.movieDetails.release_date.split('-'); | |||||
return `${month}/${day}/${year}`; | |||||
}, | |||||
runtime(): string { | |||||
if (!this.movieDetails || !this.movieDetails.runtime) { | |||||
return ""; | |||||
} | |||||
let hours = Math.floor(this.movieDetails.runtime / 60); | |||||
let minutes = Math.floor(this.movieDetails.runtime % 60); | |||||
return (hours > 0 ? `${hours}h ` : "") + `${minutes}m`; | |||||
} | |||||
}, | |||||
data() { | |||||
return { | |||||
movieDetails: <IMovieDetails|undefined>undefined | |||||
} | |||||
}, | |||||
methods: { | |||||
async displayMovie() { | |||||
let response = <MovieDetails> await (authFetch(`/api/movie/details/${this.movie.id}`).then(response => response.json())); | |||||
this.movieDetails = response.data; | |||||
console.log(response); | |||||
} | |||||
}, | |||||
mounted() { | |||||
this.displayMovie(); | |||||
}, | |||||
props: { | |||||
imgElem: { | |||||
type: HTMLImageElement, | |||||
required: true | |||||
}, | |||||
movie: { | |||||
type: Object, | |||||
required: true | |||||
} | |||||
} | |||||
}); | |||||
</script> | |||||
<style> | |||||
.modal { | |||||
@apply fixed inset-0 flex flex-col; | |||||
background: rgba(0, 0, 0, 0.5); | |||||
} | |||||
.dot-separated > *:not(:first-child)::before { | |||||
@apply px-2; | |||||
content: '\2022' | |||||
} | |||||
</style> |
@ -1,20 +1,101 @@ | |||||
<template> | <template> | ||||
<div class="mx-auto my-auto flex flex-col"> | |||||
<span class="text-2xl">Welcome, {{ name }}</span> | |||||
<router-link :to="{name: 'Logout'}" class="bg-red-500 text-white p-3 rounded-lg shadow-md focus:outline-none hover:bg-red-600 text-center">Sign Out</router-link> | |||||
</div> | |||||
<form @submit.prevent="search"> | |||||
<div class="flex rounded-xl overflow-hidden shadow-md mb-8"> | |||||
<input type="text" v-model="searchValue" :placeholder="exampleTitles[Math.floor(exampleTitles.length*Math.random())]" | |||||
class="w-full outline-none p-2 bg-white border border-gray-100 text-gray-800 placeholder-gray-400 disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-gray-50 disabled:border-gray-400"> | |||||
<button class="py-3 px-6 bg-indigo-500 text-white" type="submit">Search</button> | |||||
</div> | |||||
</form> | |||||
<ul class="w-full grid gap-8 grid-cols-2 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10 self-center "> | |||||
<li class="inline-block" v-for="(movie, index) in movies" @click="activeMovie = index"> | |||||
<div class="w-full h-full flex shadow-lg bg-gray-300 text-gray-500 text-4xl overflow-hidden rounded-xl cursor-pointer motion-safe:transform hover:scale-105 transition-transform ease-out"> | |||||
<i v-if="!movie.poster_path" class="fas fa-image mx-auto my-auto"></i> | |||||
<img v-else class="w-full h-full object-cover" loading="lazy" :ref="`poster-${index}`" | |||||
:src="`/api/tmdb/image/w185${movie.poster_path}`"> | |||||
</div> | |||||
<div> | |||||
<h3></h3> | |||||
</div> | |||||
</li> | |||||
</ul> | |||||
<movie-modal v-if="activeMovie != -1" :movie="movies[activeMovie]" :img-elem="$refs[`poster-${activeMovie}`]"/> | |||||
</template> | </template> | ||||
<script lang="ts"> | <script lang="ts"> | ||||
import { defineComponent } from "vue"; | import { defineComponent } from "vue"; | ||||
import * as auth from "../auth"; | |||||
import { IApiDataResponse } from "@common/api_schema"; | |||||
import { IMovieDetails, IMovieSearchResult, IPaginatedResponse } from "@lib/tmdb/schema"; | |||||
import MovieModal from "../components/MovieModal.vue"; | |||||
import { authFetch } from "../auth"; | |||||
type MovieResults = IApiDataResponse<IPaginatedResponse<IMovieSearchResult>>; | |||||
export default defineComponent({ | export default defineComponent({ | ||||
components: {}, | |||||
components: { | |||||
MovieModal | |||||
}, | |||||
data() { | data() { | ||||
return { | return { | ||||
name: auth.getUser().name.split(' ')[0] | |||||
activeMovie: -1, | |||||
exampleTitles: ["John Wick", "The Incredibles 2", "Stand by Me", "Shawshank Redemption", "The Dark Knight", "Pulp Fiction", "Forest Gump"], | |||||
expandedIndex: -1, | |||||
isSubmitting: false, | |||||
movies: <IMovieSearchResult[]>[], | |||||
searchValue: <string>this.$route.query["query"] || "", | |||||
page: -1, | |||||
totalPages: 0, | |||||
totalResults: 0 | |||||
} | |||||
}, | |||||
methods: { | |||||
async search() { | |||||
if (this.isSubmitting || this.searchValue.trim().length == 0) { | |||||
return; | |||||
} | |||||
this.$router.replace({ name: "Home", query: { query: this.searchValue } }); | |||||
this.isSubmitting = true; | |||||
try { | |||||
let response = <MovieResults> await (authFetch(`/api/movie/search?query=${encodeURI(this.searchValue)}`).then(response => response.json())); | |||||
this.movies = response.data.results; | |||||
this.page = response.data.page; | |||||
this.totalPages = response.data.total_pages; | |||||
this.totalResults = response.data.total_results; | |||||
console.log("Got results", this.totalResults); | |||||
} catch(e) { | |||||
console.log("Error fetching movies:", e); | |||||
} | |||||
this.isSubmitting = false; | |||||
} | } | ||||
}, | |||||
mounted() { | |||||
console.log("Mounted main component"); | |||||
this.search(); | |||||
} | } | ||||
}); | }); | ||||
</script> | </script> | ||||
<style scoped> | |||||
.overlay { | |||||
position: relative; | |||||
} | |||||
.overlay::before { | |||||
content: ""; | |||||
position: absolute; | |||||
left: 0; top: 0; | |||||
right: 0; | |||||
bottom: 0; | |||||
background: rgba(0, 0, 0, 0.5); | |||||
filter: grayscale(100%); | |||||
z-index: 0; | |||||
} | |||||
.overlay > * { | |||||
z-index: 1; | |||||
} | |||||
.active .body { | |||||
display: block !important; | |||||
} | |||||
</style> |
@ -0,0 +1,7 @@ | |||||
export interface IApiResponse { | |||||
status: string | |||||
} | |||||
export interface IApiDataResponse<T> extends IApiResponse { | |||||
data: T | |||||
} |
@ -1,18 +1,65 @@ | |||||
import Application from "@server/Application"; | import Application from "@server/Application"; | ||||
import TheMovieDb from "@lib/tmdb"; | |||||
import { env, secret } from "@server/util"; | |||||
import { readFile } from "fs/promises"; | |||||
import TVDB from "tvdb-v4"; | import TVDB from "tvdb-v4"; | ||||
import Service from "./Service"; | import Service from "./Service"; | ||||
import TvDb from "./TvDb"; | |||||
export default class MovieSearch extends Service | export default class MovieSearch extends Service | ||||
{ | { | ||||
protected tmdb!: TheMovieDb; | |||||
/** | |||||
* The instance of TVDB | |||||
*/ | |||||
protected tvdb!: TvDb; | |||||
public constructor(app: Application) { | public constructor(app: Application) { | ||||
super("Movie Search", app); | super("Movie Search", app); | ||||
} | } | ||||
public async boot() { | |||||
/** | |||||
* Start the service | |||||
*/ | |||||
public start() { | |||||
this.tvdb = this.app.service<TvDb>("TVDB"); | |||||
} | |||||
/** | |||||
* Boot the service | |||||
*/ | |||||
public async boot() { | |||||
let apiKey = await secret(env("TMDB_KEY_FILE")); | |||||
this.tmdb = new TheMovieDb(apiKey); | |||||
} | } | ||||
/** | |||||
* Shutdown the service | |||||
*/ | |||||
public async shutdown() { | public async shutdown() { | ||||
// no-op | |||||
} | |||||
// Interface ----------------------------------------------------------------------------------- | |||||
/** | |||||
* Get the details of a movie | |||||
*/ | |||||
public async details(id: number) { | |||||
return await this.tmdb.movie(id); | |||||
} | |||||
/** | |||||
* Search for a movie | |||||
*/ | |||||
public async search(query: string, year?: number) { | |||||
return await this.tmdb.searchMovie(query, year); | |||||
// let results = await this.tvdb.searchMovie(query, year); | |||||
// return results.map(movie => <any>{ | |||||
// image : movie.image_url ? `/api/tvdb/artwork${new URL(movie.image_url).pathname}`: null, | |||||
// name : movie.name, | |||||
// year : movie.year | |||||
// }); | |||||
} | } | ||||
} | } |
@ -1,23 +1,27 @@ | |||||
import { FastifyReply, FastifyRequest, RouteHandlerMethod } from "fastify"; | import { FastifyReply, FastifyRequest, RouteHandlerMethod } from "fastify"; | ||||
export type HandlerMethod = (request: FastifyRequest, reply: FastifyReply) => void; | |||||
export type HandlerMethod = (request: FastifyRequest, reply: FastifyReply, next?: any) => void; | |||||
export type MiddlewareMethod = (request: FastifyRequest, reply: FastifyReply, next: () => void) => void; | export type MiddlewareMethod = (request: FastifyRequest, reply: FastifyReply, next: () => void) => void; | ||||
export type IteratorNext = () => void; | export type IteratorNext = () => void; | ||||
/** | /** | ||||
* A route handler that supports middleware methods | * A route handler that supports middleware methods | ||||
*/ | */ | ||||
export function handleMiddleware(middleware: MiddlewareMethod[], handler: RouteHandlerMethod) { | |||||
return <HandlerMethod> (async (request: FastifyRequest, reply: FastifyReply) => { | |||||
export function handleMiddleware(middleware: MiddlewareMethod[], handler?: RouteHandlerMethod) { | |||||
return <HandlerMethod> (async (request, reply, next) => { | |||||
var iterator = middleware[Symbol.iterator](); | var iterator = middleware[Symbol.iterator](); | ||||
var next = async () => { | |||||
var nextMiddleware = async () => { | |||||
let result = iterator.next(); | let result = iterator.next(); | ||||
if (result.done) { | if (result.done) { | ||||
(<any>handler)(request, reply); | |||||
if (handler) { | |||||
(<any>handler)(request, reply); | |||||
} else if (next !== undefined) { | |||||
next(); | |||||
} | |||||
return; | return; | ||||
} | } | ||||
result.value(request, reply, next); | |||||
result.value(request, reply, nextMiddleware); | |||||
}; | }; | ||||
next(); | |||||
nextMiddleware(); | |||||
}); | }); | ||||
} | } |
@ -0,0 +1,14 @@ | |||||
import { constraints } from "@common/validation"; | |||||
import { FastifyRequest } from "fastify"; | |||||
import validate from "validate.js"; | |||||
import Request from "./Request"; | |||||
export default class MovieSearchRequest extends Request | |||||
{ | |||||
public validate(request: FastifyRequest) { | |||||
return validate.async(request.query, { | |||||
query: constraints.api.movie.search.query, | |||||
year: constraints.api.movie.search.year | |||||
},<any>{ fullMessages: false }); | |||||
} | |||||
} |
@ -1,11 +1,27 @@ | |||||
import Application from "@server/Application"; | import Application from "@server/Application"; | ||||
import { FastifyInstance } from "fastify"; | |||||
import MovieSearch from "@server/services/MovieSearch"; | |||||
import { auth } from "../middleware/auth"; | |||||
import RouteRegisterFactory from "./RouteRegisterFactory"; | |||||
/** | /** | ||||
* Register API routes | * Register API routes | ||||
*/ | */ | ||||
export default function register(server: FastifyInstance, app: Application) { | |||||
export default function register(factory: RouteRegisterFactory, app: Application) { | |||||
server.get("/api/movie/search", async (request, reply) => { }); | |||||
factory.proxy("/api/tvdb/artwork", "https://artworks.thetvdb.com/"); | |||||
factory.proxy("/api/tmdb/image", "https://image.tmdb.org/t/p/"); | |||||
factory.get("/api/movie/search", [auth], async (request, reply) => { | |||||
let query = <string>(<any>request.query)["query"]; | |||||
let year = parseInt((<any>request.query)["year"]) || undefined; | |||||
let results = await app.service<MovieSearch>("Movie Search").search(query, year); | |||||
reply.send({ status: "success", data: results }); | |||||
}); | |||||
factory.get("/api/movie/details/:id", [auth], async (request, reply) => { | |||||
let id = parseInt((<any>request.params)["id"]); | |||||
let results = await app.service<MovieSearch>("Movie Search").details(id); | |||||
reply.send({ status: "success", data: results}); | |||||
}); | |||||
} | } | ||||
@ -1,11 +1,25 @@ | |||||
export function hasAllProperties(obj: any, properties: string[]) { | |||||
if (Object.keys(obj).length !== properties.length) { | |||||
return false; | |||||
import assert from "assert"; | |||||
import { readFile } from "fs/promises"; | |||||
import { readFileSync } from "fs"; | |||||
/** | |||||
* Fetch an environment variable | |||||
*/ | |||||
export function env(variable: string, throwIfNotFound = true) { | |||||
let value = process.env[variable]; | |||||
if (throwIfNotFound) { | |||||
assert(value !== undefined); | |||||
} | } | ||||
for (let key of properties) { | |||||
if (!(key in obj)) { | |||||
return false; | |||||
} | |||||
} | |||||
return true; | |||||
return <string>value; | |||||
} | |||||
/** | |||||
* Fetch a secret from a file | |||||
*/ | |||||
export async function secret(path: string) { | |||||
return (await readFile(path)).toString().trim(); | |||||
} | |||||
export function secretSync(path: string) { | |||||
return readFileSync(path).toString().trim(); | |||||
} | } |