import config from 'config'; import { differenceInYears } from 'date-fns'; import { unit } from 'mathjs'; import { knexOwner as knex, knexManticore } from './knex.js'; import { utilsApi } from './manticore.js'; import { HttpError } from './errors.js'; import { fetchCountriesByAlpha2 } from './countries.js'; import { curateEntity } from './entities.js'; import { curateMedia } from './media.js'; import { curateStash } from './stashes.js'; import escape from '../utils/escape-manticore.js'; import slugify from '../utils/slugify.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, alias: actor.birth_country_alias, }, city: actor.birth_city, state: actor.birth_state, }, residence: actor.residence_country_alpha2 && { country: actor.residence_country_alpha2 && { alpha2: actor.residence_country_alpha2, name: actor.residence_country_name, alias: actor.residence_country_alias, }, city: actor.residence_city, state: actor.residence_state, }, avatar: curateMedia(actor.avatar), profiles: context.profiles?.map((profile) => ({ id: profile.id, description: profile.description, descriptionHash: profile.description_hash, entity: curateEntity({ ...profile.entity, parent: profile.parent_entity }), avatar: curateMedia(profile.avatar), })), createdAt: actor.created_at, updatedAt: actor.updated_at, likes: actor.stashed, stashes: context.stashes?.map((stash) => curateStash(stash)) || [], ...context.append?.[actor.id], }; } export function sortActorsByGender(actors, context = {}) { if (!actors) { return actors; } const alphaActors = actors.sort((actorA, actorB) => actorA.name.localeCompare(actorB.name, 'en')); const genderActors = ['transsexual', 'female', undefined, null, 'male'].flatMap((gender) => alphaActors.filter((actor) => actor.gender === gender)); const titleSlug = slugify(context.title); const titleActors = titleSlug ? genderActors.sort((actorA, actorB) => { const actorASlug = actorA.slug.split('-')[0]; const actorBSlug = actorB.slug.split('-')[0]; if (titleSlug.includes(actorASlug) && !titleSlug.includes(actorBSlug)) { return -1; } if (titleSlug.includes(actorBSlug) && !titleSlug.includes(actorASlug)) { return 1; } return 0; }) : alphaActors; return titleActors; } export async function fetchActorsById(actorIds, options = {}, reqUser) { const [actors, profiles, stashes] = await Promise.all([ knex('actors') .select( 'actors.*', 'actors_meta.*', 'birth_countries.alpha2 as birth_country_alpha2', knex.raw('COALESCE(birth_countries.alias, birth_countries.name) as birth_country_name'), 'residence_countries.alpha2 as residence_country_alpha2', knex.raw('COALESCE(residence_countries.alias, residence_countries.name) as residence_country_name'), ) .leftJoin('actors_meta', 'actors_meta.actor_id', 'actors.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') .whereIn('actors.id', actorIds) .modify((builder) => { if (options.order) { builder.orderBy(...options.order); } }), knex('actors_profiles') .select('actors_profiles.*', knex.raw('row_to_json(entities) as entity'), knex.raw('row_to_json(parents) as parent_entity'), knex.raw('row_to_json(media) as avatar')) .leftJoin('actors', 'actors.id', 'actors_profiles.actor_id') .leftJoin('entities', 'entities.id', 'actors_profiles.entity_id') .leftJoin('entities as parents', 'parents.id', 'entities.parent_id') .leftJoin('media', 'media.id', 'actors_profiles.avatar_media_id') .whereIn('actor_id', actorIds) .groupBy('actors_profiles.id', 'entities.id', 'parents.id', 'media.id'), reqUser ? knex('stashes_actors') .leftJoin('stashes', 'stashes.id', 'stashes_actors.stash_id') .where('stashes.user_id', reqUser.id) .whereIn('stashes_actors.actor_id', actorIds) : [], ]); if (options.order) { return actors.map((actorEntry) => curateActor(actorEntry, { stashes: stashes.filter((stash) => stash.actor_id === actorEntry.id), 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, { stashes: stashes.filter((stash) => stash.actor_id === actor.id), profiles: profiles.filter((profile) => profile.actor_id === actor.id), 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, aggregateCountries: true, requireAvatar: options?.requireAvatar || false, order: [escape(options.order?.[0]) || 'name', escape(options.order?.[1]) || 'asc'], }; } async function queryManticoreSql(filters, options, _reqUser) { const aggSize = config.database.manticore.maxAggregateSize; const sqlQuery = knexManticore.raw(` :query: OPTION max_matches=:maxMatches:, max_query_time=:maxQueryTime: :countriesFacet:; show meta; `, { query: knexManticore(filters.stashId ? 'actors_stashed' : 'actors') .modify((builder) => { if (filters.stashId) { builder.select(knex.raw(` actors.id as id, actors.slug, actors.gender as gender, actors.country as country, actors.height as height, actors.mass as mass, actors.cup as cup, actors.natural_boobs as natural_boobs, actors.date_of_birth as date_of_birth, actors.has_avatar as has_avatar, actors.scenes as scenes, actors.stashed as stashed, created_at as stashed_at, if(actors.date_of_birth, floor((now() - actors.date_of_birth) / 31556952), 0) as age, weight() as _score `)); builder .innerJoin('actors', 'actors.id', 'actors_stashed.actor_id') .where('stash_id', filters.stashId); } else { builder.select(knex.raw('*, weight() as _score')); } if (filters.query) { builder.whereRaw('match(\'@name :query:\', actors)', { query: escape(filters.query) }); } // attribute filters ['country'].forEach((attribute) => { if (filters[attribute]) { builder.where(attribute, filters[attribute]); } }); if (filters.gender === 'other') { builder.whereNull('gender'); } else if (filters.gender) { builder.where('gender', filters.gender); } if (filters.age) { builder.select('if(date_of_birth, floor((now() - date_of_birth) / 31556952), 0) as age'); } // range filters ['age', 'height'].forEach((attribute) => { if (filters[attribute]) { builder .where(attribute, '>=', filters[attribute][0]) .where(attribute, '<=', filters[attribute][1]); } }); if (filters.weight) { // weight is a reserved keyword in manticore builder .where('mass', '>=', filters.weight[0]) .where('mass', '<=', filters.weight[1]); } if (filters.dateOfBirth && filters.dobType === 'dateOfBirth') { builder.where('date_of_birth', Math.floor(filters.dateOfBirth.getTime() / 1000)); } if (filters.dateOfBirth && filters.dobType === 'birthday') { const month = filters.dateOfBirth.getMonth() + 1; const day = filters.dateOfBirth.getDate(); builder.select('month(date_of_birth) as month_of_birth, day(date_of_birth) as day_of_birth'); builder .where('month_of_birth', month) .where('day_of_birth', day); } if (filters.cup) { builder.select(`regex(actors.cup, '^[${filters.cup[0]}-${filters.cup[1]}]') as cup_in_range`); builder.where('cup_in_range', 1); } if (typeof filters.naturalBoobs === 'boolean') { builder.where('natural_boobs', filters.naturalBoobs ? 2 : 1); // manticore boolean does not support null, so 0 = null, 1 = false (enhanced), 2 = true (natural) } if (filters.requireAvatar) { builder.where('has_avatar', 1); } if (options.order?.[0] === 'name') { builder.orderBy('actors.slug', options.order[1]); } else if (options.order?.[0] === 'likes') { builder.orderBy([ { column: 'actors.stashed', order: options.order[1] }, { column: 'actors.slug', order: 'asc' }, ]); } else if (options.order?.[0] === 'scenes') { builder.orderBy([ { column: 'actors.scenes', order: options.order[1] }, { column: 'actors.slug', order: 'asc' }, ]); } else if (options.order?.[0] === 'results') { builder.orderBy([ { column: '_score', order: options.order[1] }, { column: 'actors.stashed', order: 'desc' }, { column: 'actors.slug', order: 'asc' }, ]); } else if (options.order?.[0] === 'stashed' && filters.stashId) { builder.orderBy([ { column: 'stashed_at', order: options.order[1] }, { column: 'actors.slug', order: 'asc' }, ]); } else if (options.order) { builder.orderBy([ { column: `actors.${options.order[0]}`, order: options.order[1] }, { column: 'actors.slug', order: 'asc' }, ]); } else { builder.orderBy('actors.slug', 'asc'); } }) .limit(options.limit) .offset((options.page - 1) * options.limit) .toString(), // option threads=1 fixes actors, but drastically slows down performance, wait for fix countriesFacet: options.aggregateCountries ? knex.raw('facet actors.country order by count(*) desc limit :aggSize', { aggSize }) : null, maxMatches: config.database.manticore.maxMatches, maxQueryTime: config.database.manticore.maxQueryTime, }).toString(); // manticore does not seem to accept table.column syntax if 'table' is primary (yet?), crude work-around const curatedSqlQuery = filters.stashId ? sqlQuery : sqlQuery.replace(/actors\./g, ''); if (process.env.NODE_ENV === 'development') { console.log(curatedSqlQuery); } const results = await utilsApi.sql(curatedSqlQuery); const countries = results .find((result) => (result.columns[0].country || result.columns[0]['actors.country']) && result.columns[1]['count(*)']) ?.data.map((row) => ({ key: row.country || row['actors.country'], doc_count: row['count(*)'] })).filter((country) => !!country.key) || []; const total = Number(results.at(-1).data.find((entry) => entry.Variable_name === 'total_found').Value); return { actors: results[0].data, total, aggregations: { countries, }, }; } export async function fetchActors(filters, rawOptions, reqUser) { const options = curateOptions(rawOptions); console.log('filters', filters); console.log('options', options); const result = await queryManticoreSql(filters, options, reqUser); // console.log('result', result); const actorIds = result.actors.map((actor) => Number(actor.id)); const [actors, countries] = await Promise.all([ fetchActorsById(actorIds, {}, reqUser), fetchCountriesByAlpha2(result.aggregations.countries.map((bucket) => bucket.key)), ]); return { actors, countries, total: result.total, limit: options.limit, }; }