Reinitialized commit. Update and actors overview with some filters.

This commit is contained in:
2023-12-30 06:29:53 +01:00
commit 3f099b5e95
1208 changed files with 134732 additions and 0 deletions

151
src/actors.js Normal file
View File

@@ -0,0 +1,151 @@
import { differenceInYears } from 'date-fns';
import knex from './knex.js';
import { searchApi } from './manticore.js';
import { HttpError } from './errors.js';
export function curateActor(actor, context = {}) {
return {
id: actor.id,
slug: actor.slug,
name: actor.name,
gender: actor.gender,
age: actor.age,
dateOfBirth: actor.date_of_birth,
ageFromBirth: actor.date_of_birth && differenceInYears(Date.now(), actor.date_of_birth),
ageThen: context.sceneDate && actor.date_of_birth && differenceInYears(context.sceneDate, actor.date_of_birth),
birthCountry: actor.birth_country_alpha2 && {
alpha2: actor.birth_country_alpha2,
name: actor.birth_country_name,
},
residenceCountry: actor.residence_country_alpha2 && {
alpha2: actor.residence_country_alpha2,
name: actor.residence_country_name,
},
avatar: actor.avatar_id ? {
id: actor.avatar_id,
path: actor.avatar_path,
thumbnail: actor.avatar_thumbnail,
lazy: actor.avatar_lazy,
isS3: actor.avatar_s3,
} : null,
};
}
export function sortActorsByGender(actors) {
if (!actors) {
return actors;
}
const alphaActors = actors.sort((actorA, actorB) => actorA.name.localeCompare(actorB.name, 'en'));
const genderActors = ['transsexual', 'female', 'male', undefined].flatMap((gender) => alphaActors.filter((actor) => actor.gender === gender));
return genderActors;
}
export async function fetchActorsById(actorIds) {
const [actors] = await Promise.all([
knex('actors')
.select(
'actors.*',
'avatars.id as avatar_id',
'avatars.path as avatar_path',
'avatars.thumbnail as avatar_thumbnail',
'avatars.lazy as avatar_lazy',
'avatars.width as avatar_width',
'avatars.height as avatar_height',
'avatars.is_s3 as avatar_s3',
)
.whereIn('actors.id', actorIds)
.leftJoin('media as avatars', 'avatars.id', 'actors.avatar_media_id')
.groupBy('actors.id', 'avatars.id'),
]);
return actorIds.map((actorId) => {
const actor = actors.find((actorEntry) => actorEntry.id === actorId);
if (!actor) {
return null;
}
return curateActor(actor);
}).filter(Boolean);
}
function curateOptions(options) {
if (options?.limit > 100) {
throw new HttpError('Limit must be <= 100', 400);
}
return {
page: options?.page || 1,
limit: options?.limit || 30,
requireAvatar: options?.requireAvatar || false,
};
}
function buildQuery(filters) {
console.log(filters);
const query = {
bool: {
must: [],
},
};
if (filters.query) {
query.bool.must.push({
match: {
name: filters.query,
},
});
}
['age', 'height', 'weight'].forEach((attribute) => {
if (filters[attribute]) {
query.bool.must.push({
range: {
[attribute]: {
gte: filters[attribute][0],
lte: filters[attribute][1],
},
},
});
}
});
if (filters.requireAvatar) {
query.bool.must.push({
equals: {
has_avatar: 1,
},
});
}
return query;
}
export async function fetchActors(filters, rawOptions) {
const options = curateOptions(rawOptions);
const query = buildQuery(filters);
const result = await searchApi.search({
index: 'actors',
query,
expressions: {
age: 'if(date_of_birth, floor((now() - date_of_birth) / 31556952), 0)',
},
limit: options.limit,
offset: (options.page - 1) * options.limit,
sort: [{ slug: 'asc' }],
});
const actorIds = result.hits.hits.map((hit) => Number(hit._id));
const actors = await fetchActorsById(actorIds);
return {
actors,
total: result.hits.total,
limit: options.limit,
};
}

88
src/api.js Normal file
View File

@@ -0,0 +1,88 @@
const postHeaders = {
mode: 'cors',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
},
};
function getQuery(data) {
if (!data) {
return '';
}
const curatedQuery = Object.fromEntries(Object.entries(data).map(([key, value]) => (value === undefined ? null : [key, value])).filter(Boolean));
return `?${encodeURI(decodeURIComponent(new URLSearchParams(curatedQuery).toString()))}`; // recode so commas aren't encoded
}
export async function get(path, query = {}) {
const res = await fetch(`/api${path}${getQuery(query)}`);
const body = await res.json();
if (res.ok) {
return body;
}
throw new Error(body.message);
}
export async function post(path, data, { query } = {}) {
const res = await fetch(`/api${path}${getQuery(query)}`, {
method: 'POST',
body: JSON.stringify(data),
...postHeaders,
});
if (res.status === 204) {
return null;
}
const body = await res.json();
if (res.ok) {
return body;
}
throw new Error(body.statusMessage);
}
export async function patch(path, data, { query } = {}) {
const res = await fetch(`/api${path}${getQuery(query)}`, {
method: 'PATCH',
body: JSON.stringify(data),
...postHeaders,
});
if (res.status === 204) {
return null;
}
const body = await res.json();
if (res.ok) {
return body;
}
throw new Error(body.message);
}
export async function del(path, { data, query } = {}) {
const res = await fetch(`/api${path}${getQuery(query)}`, {
method: 'DELETE',
body: JSON.stringify(data),
...postHeaders,
});
if (res.status === 204) {
return null;
}
const body = await res.json();
if (res.ok) {
return body;
}
throw new Error(body.message);
}

16
src/errors.js Executable file
View File

@@ -0,0 +1,16 @@
export class HttpError extends Error {
constructor(message, httpCode, friendlyMessage, data) {
super(message);
this.name = 'HttpError';
this.httpCode = httpCode;
if (friendlyMessage) {
this.friendlyMessage = friendlyMessage;
}
if (data) {
this.data = data;
}
}
}

19
src/format.js Normal file
View File

@@ -0,0 +1,19 @@
import { format } from 'date-fns';
export function formatDuration(duration, forceHours) {
const hours = Math.floor(duration / 3600);
const minutes = Math.floor((duration % 3600) / 60);
const seconds = Math.floor(duration % 60);
const [formattedHours, formattedMinutes, formattedSeconds] = [hours, minutes, seconds].map((segment) => segment.toString().padStart(2, '0'));
if (duration >= 3600 || forceHours) {
return `${formattedHours}:${formattedMinutes}:${formattedSeconds}`;
}
return `${formattedMinutes}:${formattedSeconds}`;
}
export function formatDate(date, template) {
return format(date, template);
}

9
src/get-title.js Normal file
View File

@@ -0,0 +1,9 @@
import config from 'config';
export default function getTitle(location) {
if (!location) {
return config.title;
}
return `${config.title} - ${location.slice(0, 1).toUpperCase()}${location.slice(1)}`;
}

11
src/knex.js Executable file
View File

@@ -0,0 +1,11 @@
import config from 'config';
import knex from 'knex';
export default knex({
client: 'pg',
connection: config.database.owner,
pool: config.database.pool,
// performance overhead, don't use asyncStackTraces in production
asyncStackTraces: process.env.NODE_ENV === 'development',
// debug: process.env.NODE_ENV === 'development',
});

8
src/manticore.js Normal file
View File

@@ -0,0 +1,8 @@
import config from 'config';
import manticore from 'manticoresearch';
const mantiClient = new manticore.ApiClient();
mantiClient.basePath = `http://${config.database.manticore.host}:${config.database.manticore.httpPort}`;
export const searchApi = new manticore.SearchApi(mantiClient);

18
src/navigate.js Normal file
View File

@@ -0,0 +1,18 @@
export default function navigate(path, query, options = {}) {
const curatedQuery = Object.fromEntries(Object.entries(query || {}).map(([key, value]) => (value === undefined ? null : [key, value])).filter(Boolean));
const queryString = new URLSearchParams({
...(options.preserveQuery ? Object.fromEntries(new URL(window.location).searchParams.entries()) : {}),
...curatedQuery,
}).toString();
const url = queryString
? `${path}?${encodeURI(decodeURIComponent(queryString))}` // URLSearchParams encodes commas, we don't want that
: path;
if (options.redirect) {
window.location.href = url;
} else {
history.pushState({}, '', url); // eslint-disable-line no-restricted-globals
}
}

246
src/scenes.js Normal file
View File

@@ -0,0 +1,246 @@
import knex from './knex.js';
import { searchApi } from './manticore.js';
import { HttpError } from './errors.js';
import { curateActor, sortActorsByGender } from './actors.js';
function curateMedia(media) {
if (!media) {
return null;
}
return {
id: media.id,
path: media.path,
thumbnail: media.thumbnail,
lazy: media.lazy,
isS3: media.is_s3,
width: media.width,
height: media.height,
};
}
function curateScene(rawScene, assets) {
if (!rawScene) {
return null;
}
console.log(assets.channel);
return {
id: rawScene.id,
title: rawScene.title,
slug: rawScene.slug,
url: rawScene.url,
date: rawScene.date,
createdAt: rawScene.created_at,
effectiveDate: rawScene.effective_date,
description: rawScene.description,
duration: rawScene.duration,
channel: {
id: assets.channel.id,
slug: assets.channel.slug,
name: assets.channel.name,
type: assets.channel.type,
isIndependent: assets.channel.independent,
hasLogo: assets.channel.has_logo,
},
network: assets.channel.network_id ? {
id: assets.channel.network_id,
slug: assets.channel.network_slug,
name: assets.channel.network_name,
type: assets.channel.network_type,
hasLogo: assets.channel.has_logo,
} : null,
actors: sortActorsByGender(assets.actors.map((actor) => curateActor(actor, { sceneDate: rawScene.effective_date }))),
directors: assets.directors.map((director) => ({
id: director.id,
slug: director.slug,
name: director.name,
})),
tags: assets.tags.map((tag) => ({
id: tag.id,
slug: tag.slug,
name: tag.name,
})),
poster: curateMedia(assets.poster),
photos: assets.photos.map((photo) => curateMedia(photo)),
createdBatchId: rawScene.created_batch_id,
updatedBatchId: rawScene.updated_batch_id,
};
}
function curateOptions(options) {
if (options?.limit > 100) {
throw new HttpError('Limit must be <= 100', 400);
}
return {
limit: options.limit || 30,
};
}
export async function fetchScenesById(sceneIds) {
const [scenes, channels, actors, directors, tags, posters, photos] = await Promise.all([
knex('releases').whereIn('id', sceneIds),
knex('releases')
.select('channels.*', 'networks.id as network_id', 'networks.slug as network_slug', 'networks.name as network_name', 'networks.type as network_type')
.whereIn('releases.id', sceneIds)
.leftJoin('entities as channels', 'channels.id', 'releases.entity_id')
.leftJoin('entities as networks', 'networks.id', 'channels.parent_id')
.groupBy('channels.id', 'networks.id'),
knex('releases_actors')
.select(
'actors.*',
'releases_actors.release_id',
'avatars.id as avatar_id',
'avatars.path as avatar_path',
'avatars.thumbnail as avatar_thumbnail',
'avatars.lazy as avatar_lazy',
'avatars.width as avatar_width',
'avatars.height as avatar_height',
'avatars.is_s3 as avatar_s3',
'birth_countries.alpha2 as birth_country_alpha2',
'birth_countries.name as birth_country_name',
'residence_countries.alpha2 as residence_country_alpha2',
'residence_countries.name as residence_country_name',
)
.whereIn('release_id', sceneIds)
.leftJoin('actors', 'actors.id', 'releases_actors.actor_id')
.leftJoin('media as avatars', 'avatars.id', 'actors.avatar_media_id')
.leftJoin('countries as birth_countries', 'birth_countries.alpha2', 'actors.birth_country_alpha2')
.leftJoin('countries as residence_countries', 'residence_countries.alpha2', 'actors.residence_country_alpha2'),
knex('releases_directors')
.whereIn('release_id', sceneIds)
.leftJoin('actors as directors', 'directors.id', 'releases_directors.director_id'),
knex('releases_tags')
.select('id', 'slug', 'name', 'release_id')
.whereNotNull('tags.id')
.whereIn('release_id', sceneIds)
.leftJoin('tags', 'tags.id', 'releases_tags.tag_id'),
knex('releases_posters')
.whereIn('release_id', sceneIds)
.leftJoin('media', 'media.id', 'releases_posters.media_id'),
knex('releases_photos')
.whereIn('release_id', sceneIds)
.leftJoin('media', 'media.id', 'releases_photos.media_id'),
]);
return sceneIds.map((sceneId) => {
const scene = scenes.find((sceneEntry) => sceneEntry.id === sceneId);
if (!scene) {
return null;
}
const sceneChannel = channels.find((entity) => entity.id === scene.entity_id);
const sceneActors = actors.filter((actor) => actor.release_id === sceneId);
const sceneDirectors = directors.filter((director) => director.release_id === sceneId);
const sceneTags = tags.filter((tag) => tag.release_id === sceneId);
const scenePoster = posters.find((poster) => poster.release_id === sceneId);
const scenePhotos = photos.filter((photo) => photo.release_id === sceneId);
return curateScene(scene, {
channel: sceneChannel,
actors: sceneActors,
directors: sceneDirectors,
tags: sceneTags,
poster: scenePoster,
photos: scenePhotos,
});
}).filter(Boolean);
}
export async function fetchLatest(page, rawOptions) {
const { limit } = curateOptions(rawOptions);
const result = await searchApi.search({
index: 'scenes',
query: {
bool: {
must: [
{
range: {
effective_date: {
lte: Math.round(Date.now() / 1000),
},
},
},
],
/*
must_not: [
{
in: {
'any(tag_ids)': [101, 180, 32],
},
},
],
*/
},
},
limit,
offset: (page - 1) * limit,
sort: [{ effective_date: 'desc' }],
});
const sceneIds = result.hits.hits.map((hit) => Number(hit._id));
const scenes = await fetchScenesById(sceneIds);
return {
scenes,
total: result.hits.total,
limit,
};
}
export async function fetchUpcoming(page, rawOptions) {
const { limit } = curateOptions(rawOptions);
const result = await searchApi.search({
index: 'scenes',
query: {
bool: {
must: [
{
range: {
effective_date: {
gt: Math.round(Date.now() / 1000),
},
},
},
],
},
},
limit,
offset: (page - 1) * limit,
sort: [{ effective_date: 'asc' }],
});
const sceneIds = result.hits.hits.map((hit) => Number(hit._id));
const scenes = await fetchScenesById(sceneIds);
return {
scenes,
total: result.hits.total,
limit,
};
}
export async function fetchNew(page, rawOptions) {
const { limit } = curateOptions(rawOptions);
const result = await searchApi.search({
index: 'scenes',
limit,
offset: (page - 1) * limit,
sort: [{ created_at: 'desc' }, { effective_date: 'asc' }],
});
const sceneIds = result.hits.hits.map((hit) => Number(hit._id));
const scenes = await fetchScenesById(sceneIds);
return {
scenes,
total: result.hits.total,
limit,
};
}

20
src/web/actors.js Normal file
View File

@@ -0,0 +1,20 @@
import { fetchActors } from '../actors.js';
export async function fetchActorsApi(req, res) {
const { actors, limit, total } = await fetchActors({
query: req.query.q,
requireAvatar: Object.hasOwn(req.query, 'avatar'),
age: req.query.age?.split(',').map((age) => Number(age)),
height: req.query.height?.split(',').map((height) => Number(height)),
weight: req.query.weight?.split(',').map((weight) => Number(weight)),
}, {
page: Number(req.query.page) || 1,
limit: Number(req.query.limit) || 50,
});
res.send({
actors,
limit,
total,
});
}

8
src/web/root.js Normal file
View File

@@ -0,0 +1,8 @@
// https://stackoverflow.com/questions/46745014/alternative-for-dirname-in-node-when-using-the-experimental-modules-flag/50052194#50052194
import { dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = `${__dirname}/..`;
export default root;

101
src/web/server.js Normal file
View File

@@ -0,0 +1,101 @@
// This file isn't processed by Vite, see https://github.com/vikejs/vike/issues/562
// Consequently:
// - When changing this file, you needed to manually restart your server for your changes to take effect.
// - To use your environment variables defined in your .env files, you need to install dotenv, see https://vike.dev/env
// - To use your path aliases defined in your vite.config.js, you need to tell Node.js about them, see https://vike.dev/path-aliases
// If you want Vite to process your server code then use one of these:
// - vavite (https://github.com/cyco130/vavite)
// - See vavite + Vike examples at https://github.com/cyco130/vavite/tree/main/examples
// - vite-node (https://github.com/antfu/vite-node)
// - HatTip (https://github.com/hattipjs/hattip)
// - You can use Bati (https://batijs.github.io/) to scaffold a Vike + HatTip app. Note that Bati generates apps that use the V1 design (https://vike.dev/migration/v1-design) and Vike packages (https://vike.dev/vike-packages)
import config from 'config';
import express from 'express';
import Router from 'express-promise-router';
import compression from 'compression';
import { renderPage } from 'vike/server'; // eslint-disable-line import/extensions
// import root from './root.js';
import { fetchActorsApi } from './actors.js'; // eslint-disable-line import/extensions
const isProduction = process.env.NODE_ENV === 'production';
async function startServer() {
const app = express();
const router = Router();
app.use(compression());
app.disable('x-powered-by');
router.use('/', express.static('public'));
router.use('/', express.static('static'));
router.use('/media', express.static(config.media.path));
// Vite integration
if (isProduction) {
// In production, we need to serve our static assets ourselves.
// (In dev, Vite's middleware serves our static assets.)
const sirv = (await import('sirv')).default;
router.use(sirv('dist/client'));
} else {
// We instantiate Vite's development server and integrate its middleware to our server.
// ⚠️ We instantiate it only in development. (It isn't needed in production and it
// would unnecessarily bloat our production server.)
const vite = await import('vite');
const viteDevMiddleware = (
await vite.createServer({
// root,
server: { middlewareMode: true },
})
).middlewares;
router.use(viteDevMiddleware);
}
router.get('/api/actors', fetchActorsApi);
// ...
// Other middlewares (e.g. some RPC middleware such as Telefunc)
// ...
// Vike middleware. It should always be our last middleware (because it's a
// catch-all middleware superseding any middleware placed after it).
router.get('*', async (req, res, next) => {
const pageContextInit = {
urlOriginal: req.originalUrl,
};
const pageContext = await renderPage(pageContextInit);
const { httpResponse } = pageContext;
if (!httpResponse) {
next();
return;
}
const {
body, statusCode, headers, earlyHints,
} = httpResponse;
if (res.writeEarlyHints) {
res.writeEarlyHints({ link: earlyHints.map((e) => e.earlyHintLink) });
}
headers.forEach(([name, value]) => res.setHeader(name, value));
res.status(statusCode);
// For HTTP streams use httpResponse.pipe() instead, see https://vike.dev/stream
res.send(body);
});
app.use(router);
const port = process.env.PORT || config.web.port || 3000;
app.listen(port);
console.log(`Server running at http://localhost:${port}`);
}
startServer();