traxxx-web/src/actors.js

375 lines
12 KiB
JavaScript

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,
};
}