import config from 'config'; import { differenceInYears } from 'date-fns'; import { unit } from 'mathjs'; import { MerkleJson } from 'merkle-json'; import moment from 'moment'; import omit from 'object.omit'; import convert from 'convert'; import initLogger from './logger.js'; 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'; import { curateRevision } from './revisions.js'; import { interpolateProfiles } from '../common/actors.mjs'; // eslint-disable-line import/namespace const logger = initLogger(); const mj = new MerkleJson(); 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), dateOfDeath: actor.date_of_death, bust: actor.bust, cup: actor.cup, waist: actor.waist, hip: actor.hip, naturalBoobs: actor.natural_boobs, boobsVolume: actor.boobs_volume, boobsImplant: actor.boobs_implant, boobsPlacement: actor.boobs_placement, boobsSurgeon: actor.boobs_surgeon, naturalButt: actor.natural_butt, buttVolume: actor.butt_volume, buttImplant: actor.butt_implant, penisLength: actor.penis_length, penisGirth: actor.penis_girth, isCircumcised: actor.is_circumcised, 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.hair_color, 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, }, agency: actor.agency, 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, scenes: actor.scenes, 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) { if (filters.query.charAt(0) === '#') { builder.where('id', Number(escape(filters.query.slice(1)))); } else { 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, }; } export async function fetchActorRevisions(revisionId, filters = {}, reqUser) { const limit = filters.limit || 50; const page = filters.page || 1; const revisions = await knex('actors_revisions') .select( 'actors_revisions.*', 'users.username as username', 'reviewers.username as reviewer_username', ) .leftJoin('users', 'users.id', 'actors_revisions.user_id') .leftJoin('users as reviewers', 'reviewers.id', 'actors_revisions.reviewed_by') .modify((builder) => { if (!['admin', 'editor'].includes(reqUser?.role) && !filters.userId && !filters.actorId) { builder.where('user_id', reqUser.id); } if (filters.userId) { if (!['admin', 'editor'].includes(reqUser?.role) && filters.userId !== reqUser.id) { throw new HttpError('You are not permitted to view revisions from other users.', 403); } builder.where('actors_revisions.user_id', filters.userId); } if (revisionId) { builder.where('actors_revisions.id', revisionId); return; } if (filters.actorId) { builder.where('actors_revisions.actor_id', filters.actorId); } if (filters.isFinalized === false) { builder.whereNull('approved'); } if (filters.isFinalized === true) { builder.whereNotNull('approved'); } }) .orderBy('created_at', 'desc') .limit(limit) .offset((page - 1) * limit); const avatarIds = Array.from(new Set(revisions.flatMap((revision) => [revision.base.avatar, revision.deltas.find((delta) => delta.key === 'avatar')?.value]))).filter(Boolean); const avatarEntries = await knex('media').whereIn('id', avatarIds); const avatars = avatarEntries.map((avatar) => curateMedia(avatar)); const curatedRevisions = revisions.map((revision) => curateRevision(revision)); return { revisions: curatedRevisions, revision: revisionId && curatedRevisions.find((revision) => revision.id === revisionId), avatars, }; } const keyMap = { avatar: 'avatar_media_id', dateOfBirth: 'date_of_birth', dateOfDeath: 'date_of_death', originCountry: 'birth_country_alpha2', originState: 'birth_state', originCity: 'birth_city', residenceCountry: 'residence_country_alpha2', residenceState: 'residence_state', residenceCity: 'residence_city', hairColor: 'hair_color', naturalBoobs: 'natural_boobs', boobsVolume: 'boobs_volume', boobsImplant: 'boobs_implant', boobsPlacement: 'boobs_placement', boobsSurgeon: 'boobs_surgeon', naturalButt: 'natural_butt', buttVolume: 'butt_volume', buttImplant: 'butt_implant', penisLength: 'penis_length', penisGirth: 'penis_girth', isCircumcised: 'circumcised', hasTattoos: 'has_tattoos', hasPiercings: 'has_piercings', }; async function applyActorValueDelta(profileId, delta, trx) { return knex('actors_profiles') .where('id', profileId) .update(keyMap[delta.key] || delta.key, delta.value) .transacting(trx); } async function applyActorDirectDelta(actorId, delta, trx) { return knex('actors') .where('id', actorId) .update(keyMap[delta.key] || delta.key, delta.value) .modify((builder) => { if (delta.key === 'name') { builder.update('slug', slugify(delta.value)); } }) .transacting(trx); } async function fetchMainProfile(actorId, wasCreated = false) { const profileEntry = await knex('actors_profiles') .where('actor_id', actorId) .where('entity_id', null) .first(); if (profileEntry) { return profileEntry; } if (wasCreated) { throw new HttpError('Failed to find or create main profile', 404); } await knex('actors_profiles').insert({ actor_id: actorId, entity_id: null, }); return fetchMainProfile(actorId, true); } /* async function applyMainProfile(actorId) { const [actorEntry, mainProfile] = await Promise.all([ knex('actors') .where('id', actorId) .first(), fetchMainProfile(actorId), ]); if (!actorEntry) { throw new HttpError('No actor profile found to apply main profile to', 404); } const preservedKeys = ['id']; // we start iterating from the actor entry so we don't include keys that are not yet supported by the actors table const mergedProfile = Object.fromEntries(Object.entries(actorEntry) .filter(([key]) => Object.hasOwn(mainProfile, key)) .map(([key, value]) => [key, mainProfile[key] === null || preservedKeys.includes(key) ? value : mainProfile[key]])); await knex('actors') .where('id', actorId) .update(mergedProfile); } */ async function applyActorRevision(revisionIds, reqUser) { const revisions = await knex('actors_revisions') .whereIn('id', revisionIds) .whereNull('applied_at'); // should not re-apply revision that was already applied await revisions.reduce(async (chain, revision) => { await chain; const mainProfile = await fetchMainProfile(revision.actor_id); await knex.transaction(async (trx) => { await Promise.all(revision.deltas.map(async (delta) => { if ([ 'gender', 'avatar', 'dateOfBirth', 'dateOfDeath', 'originCountry', 'originState', 'originCity', 'residenceCountry', 'residenceState', 'residenceCity', 'height', 'weight', 'bust', 'cup', 'waist', 'hip', 'naturalBoobs', 'boobsVolume', 'boobsImplant', 'boobsPlacement', 'boobsSurgeon', 'naturalButt', 'buttVolume', 'buttImplant', 'penisLength', 'penisGirth', 'isCircumcised', 'hairColor', 'eyes', 'hasTattoos', 'tattoos', 'hasPiercings', 'piercings', 'agency', ].includes(delta.key)) { return applyActorValueDelta(mainProfile.id, delta, trx); } if (delta.key === 'name' && reqUser.role === 'admin') { return applyActorDirectDelta(revision.actor_id, delta, trx); } return null; })); await knex('actors_revisions') .transacting(trx) .where('id', revision.id) .update('applied_at', knex.fn.now()); // await trx.commit(); }).catch(async (error) => { logger.error(`Failed to apply revision ${revision.id} on actor ${revision.actor_id}: ${error.message}`); }); }, Promise.resolve()); const actorIds = Array.from(new Set(revisions.map((revision) => revision.actor_id))); await interpolateProfiles(actorIds, { knex, logger, moment, slugify, omit, }); } export async function reviewActorRevision(revisionId, isApproved, { feedback }, reqUser) { if (!reqUser || reqUser.role === 'user') { throw new HttpError('You are not permitted to approve revisions', 403); } if (typeof isApproved !== 'boolean') { throw new HttpError('You must either approve or reject the revision', 400); } const updated = await knex('actors_revisions') .where('id', revisionId) .whereNull('approved') // don't rerun reviewed revision, must be forked into new revision instead .whereNull('applied_at') .update({ approved: isApproved, reviewed_at: knex.fn.now(), reviewed_by: reqUser.id, feedback, }); if (updated === 0) { throw new HttpError('This revision was already reviewed', 409); } if (isApproved) { await applyActorRevision([revisionId], reqUser); } } const cupConversions = { us: ['AA', 'A', 'B', 'C', 'D', ['DD', 'E'], ['DDD', 'F'], 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P'], // United States uk: ['AA', 'A', 'B', 'C', 'D', 'DD', 'E', 'F', 'FF', 'G', 'GG', 'H', 'HH', 'J', 'JJ', 'K', 'KK'], // United Kingdom eu: ['AA', 'A', 'B', 'C', 'D', 'E', 'F', 'G', ' H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P'], // Europe jp: ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q'], // Japan }; cupConversions.fr = cupConversions.eu; // France cupConversions.it = cupConversions.uk; // Italy cupConversions.au = cupConversions.uk; // Australia // bra band sizes const bustConversions = { us: [28, 30, 32, 34, 36, 38, 40, 42, 44, 46, 48, 50, 52, 54, 56], eu: [60, 65, 70, 75, 80, 85, 90, 95, 100, 105, 110, 115, 120, 125, 130], fr: [75, 80, 85, 90, 95, 100, 105, 110, 115, 120, 125, 130, 135, 140, 145], it: [0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], au: [6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 34], }; bustConversions.uk = bustConversions.us; bustConversions.jp = bustConversions.eu; const conversions = { cup: cupConversions, bust: bustConversions, }; // to US function convertFigure(domain = 'cup', rawValue, units) { if (!rawValue) { return null; } const value = typeof rawValue === 'string' ? rawValue.toUpperCase() : Number(rawValue); if (!units || !cupConversions[units] || units === 'us') { return value; } if (!conversions[domain]) { if (['us', 'uk'].includes(units)) { return value; // should already be in inches } return Math.round(convert(value, 'cm').to('inches')); } if (Number.isNaN(value)) { return value; } const valueIndex = conversions[domain][units].findIndex((chartValue) => (Array.isArray(chartValue) // US uses both DD and E, and DDD and F ? chartValue.includes(value) : value === chartValue)); const usValue = Array.isArray(conversions[domain].us[valueIndex]) ? conversions[domain].us[valueIndex][0] : conversions[domain].us[valueIndex]; return usValue; } export async function createActorRevision(actorId, { edits, comment, apply, ...options }, reqUser) { const [ [actor], openRevisions, ] = await Promise.all([ fetchActorsById([actorId], { reqUser, includeAssets: true, includePartOf: true, }), knex('actors_revisions') .where('user_id', reqUser.id) .whereNull('approved'), ]); if (!actor) { throw new HttpError(`No actor with ID ${actorId} found to update`, 404); } if (openRevisions.length >= config.revisions.unapprovedLimit && reqUser.role !== 'admin') { throw new HttpError(`You have ${config.revisions.unapprovedLimit} unapproved revisions, please wait for approval before submitting another revision.`, 429); } const baseActor = Object.fromEntries(Object.entries(actor).map(([key, values]) => { if ([ 'scenes', 'likes', 'stashes', 'profiles', ].includes(key)) { return null; } /* avatar should return id if (values?.hash) { return [key, values.hash]; } */ if (values?.id) { return [key, values.id]; } if (values?.metric) { return [key, values.metric]; } if (Array.isArray(values)) { return [key, values.map((value) => value?.hash || value?.id || value)]; } return [key, values]; }).filter(Boolean)); const deltas = Object.entries(edits).map(([key, value]) => { if (baseActor[key] === value || typeof value === 'undefined') { return null; } if (Array.isArray(value)) { const valueSet = new Set(value); const baseSet = new Set(baseActor[key]); if (valueSet.size === baseSet.size && baseActor[key].every((id) => valueSet.has(id))) { return null; } return { key, value: Array.from(valueSet) }; } if (['cup', 'bust', 'waist', 'hip'].includes(key)) { const convertedValue = convertFigure(key, value, options.figureUnits); const conversionComment = !value || convertedValue === value ? null : `${key} converted from ${value} ${options.figureUnits?.toUpperCase() || 'US'} to ${convertedValue} US`; return { key, value: convertedValue, comment: conversionComment, }; } return { key, value }; }).filter(Boolean); const deltaComments = deltas.map((delta) => delta.comment); const curatedComment = [comment, ...deltaComments].filter(Boolean).join(' | '); if (deltas.length === 0) { throw new HttpError('No effective changes provided', 400); } const [revisionEntry] = await knex('actors_revisions') .insert({ user_id: reqUser.id, actor_id: actor.id, base: JSON.stringify(baseActor), deltas: JSON.stringify(deltas), hash: mj.hash({ base: baseActor, deltas, }), comment: curatedComment, }) .returning('id'); if (['admin', 'editor'].includes(reqUser.role) && apply) { // don't keep the editor waiting for the revision to apply reviewActorRevision(revisionEntry.id, true, {}, reqUser); } }