import { differenceInYears } from 'date-fns'; import { unit } from 'mathjs'; import knex from './knex.js'; import { searchApi } from './manticore.js'; import { HttpError } from './errors.js'; import { fetchCountriesByAlpha2 } from './countries.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), bust: actor.bust, cup: actor.cup, waist: actor.waist, hip: actor.hip, naturalBoobs: actor.naturalBoobs, height: actor.height && { metric: actor.height, imperial: unit(actor.height, 'cm').splitUnit(['ft', 'in']).map((value) => Math.round(value.toNumber())), }, weight: actor.weight && { metric: actor.weight, imperial: Math.round(unit(actor.weight, 'kg').toNumeric('lbs')), }, eyes: actor.eyes, hairColor: actor.hairColor, hasTattoos: actor.has_tattoos, tattoos: actor.tattoos, hasPiercings: actor.has_piercings, piercings: actor.piercings, origin: actor.birth_country_alpha2 && { country: actor.birth_country_alpha2 && { alpha2: actor.birth_country_alpha2, name: actor.birth_country_name, }, }, residence: actor.residence_country_alpha2 && { country: actor.residence_country_alpha2 && { alpha2: actor.residence_country_alpha2, name: actor.residence_country_name, }, }, avatar: actor.avatar && { id: actor.avatar.id, path: actor.avatar.path, thumbnail: actor.avatar.thumbnail, lazy: actor.avatar.lazy, isS3: actor.avatar.is_s3, }, createdAt: actor.created_at, updatedAt: actor.updated_at, likes: actor.stashed, ...context.append?.[actor.id], }; } 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, options = {}) { const [actors] = await Promise.all([ knex('actors_meta') .select('actors_meta.*') .whereIn('actors_meta.id', actorIds) .modify((builder) => { if (options.order) { builder.orderBy(...options.order); } }), ]); if (options.order) { return actors.map((actorEntry) => curateActor(actorEntry, { append: options.append })); } const curatedActors = actorIds.map((actorId) => { const actor = actors.find((actorEntry) => actorEntry.id === actorId); if (!actor) { console.warn(`Can't match actor ${actorId}`); return null; } return curateActor(actor, { append: options.append }); }).filter(Boolean); return curatedActors; } function curateOptions(options) { if (options?.limit > 120) { throw new HttpError('Limit must be <= 120', 400); } return { page: options?.page || 1, limit: options?.limit || 30, requireAvatar: options?.requireAvatar || false, order: [options.order?.[0] || 'name', options.order?.[1] || 'asc'], }; } function buildQuery(filters) { const query = { bool: { must: [], }, }; const expressions = { age: 'if(date_of_birth, floor((now() - date_of_birth) / 31556952), 0)', }; if (filters.query) { query.bool.must.push({ match: { name: filters.query, }, }); } ['gender', 'country'].forEach((attribute) => { if (filters[attribute]) { query.bool.must.push({ equals: { [attribute]: filters[attribute], }, }); } }); ['age', 'height', 'weight'].forEach((attribute) => { if (filters[attribute]) { query.bool.must.push({ range: { [attribute]: { gte: filters[attribute][0], lte: filters[attribute][1], }, }, }); } }); if (filters.dateOfBirth && filters.dobType === 'dateOfBirth') { query.bool.must.push({ equals: { date_of_birth: Math.floor(filters.dateOfBirth.getTime() / 1000), }, }); } if (filters.dateOfBirth && filters.dobType === 'birthday') { expressions.month_of_birth = 'month(date_of_birth)'; expressions.day_of_birth = 'day(date_of_birth)'; const month = filters.dateOfBirth.getMonth() + 1; const day = filters.dateOfBirth.getDate(); query.bool.must.push({ bool: { must: [ { equals: { month_of_birth: month, }, }, { equals: { day_of_birth: day, }, }, ], }, }); } if (filters.cup) { expressions.cup_in_range = `regex(cup, '^[${filters.cup[0]}-${filters.cup[1]}]')`; query.bool.must.push({ equals: { cup_in_range: 1, }, }); } if (typeof filters.naturalBoobs === 'boolean') { query.bool.must.push({ equals: { natural_boobs: filters.naturalBoobs ? 2 : 1, // manticore boolean does not support null, so 0 = null, 1 = false (enhanced), 2 = true (natural) }, }); } if (filters.requireAvatar) { query.bool.must.push({ equals: { has_avatar: 1, }, }); } return { query, expressions }; } const sortMap = { likes: 'stashed', scenes: 'scenes', relevance: '_score', }; function getSort(order) { if (order[0] === 'name') { return [{ slug: order[1], }]; } return [ { [sortMap[order[0]]]: order[1], }, { slug: 'asc', // sort by name where primary order is equal }, ]; } export async function fetchActors(filters, rawOptions) { const options = curateOptions(rawOptions); const { query, expressions } = buildQuery(filters); const result = await searchApi.search({ index: 'actors', query, expressions, limit: options.limit, offset: (options.page - 1) * options.limit, sort: getSort(options.order, filters), aggs: { countries: { terms: { field: 'country', size: 300, }, sort: [{ country: { order: 'asc' } }], }, }, }); const actorIds = result.hits.hits.map((hit) => Number(hit._id)); const [actors, countries] = await Promise.all([ fetchActorsById(actorIds), fetchCountriesByAlpha2(result.aggregations.countries.buckets.map((bucket) => bucket.key)), ]); return { actors, countries, total: result.hits.total, limit: options.limit, }; }