Compare commits

..

31 Commits

Author SHA1 Message Date
885fe7c9e9 0.51.4 2026-07-03 17:59:47 +02:00
930cc52373 Showing avatars on actor tags instead of sections. Slightly sized down scene page actor tiles, shrinking font size on long names. 2026-07-03 17:59:44 +02:00
4636a213b3 0.51.3 2026-07-03 03:59:16 +02:00
469954f613 Sorting actor tag sections by average tag weight. 2026-07-03 03:59:14 +02:00
5bdcd65d42 0.51.2 2026-07-03 03:18:44 +02:00
cb91cd4cc7 Applying global vs actor tag toggle to scene results. 2026-07-03 03:18:33 +02:00
e04ddaed9b 0.51.1 2026-07-03 02:23:15 +02:00
605da5e46c Allowing actor tag filter on stash page. 2026-07-03 02:23:13 +02:00
360e8ece85 0.51.0 2026-07-03 02:15:08 +02:00
1543bf9d03 Using packed keys instead of actor-tags table. 2026-07-03 02:15:04 +02:00
287932d9d7 0.50.23 2026-07-01 21:12:21 +02:00
7c4de31c12 Mapping all actor aliases to manticore scenes. 2026-07-01 21:11:56 +02:00
497c6150f7 0.50.22 2026-07-01 20:45:59 +02:00
514f51f111 Fixed actor merge failing if multiple source actors have profiles for same entity. 2026-07-01 20:45:57 +02:00
244dc4fff6 0.50.21 2026-06-29 00:37:21 +02:00
4b39f787c9 Including scene actor aliases in manticore. 2026-06-29 00:37:18 +02:00
fb92b9c973 0.50.20 2026-06-28 06:24:21 +02:00
181358db7d Fixed scene actors without alias returning empty actor object instead of null. 2026-06-28 06:24:19 +02:00
3790567d44 0.50.19 2026-06-28 06:18:41 +02:00
b3af993236 Fixed actor edit avatar zoom selecting avatar, showing media date. 2026-06-28 06:18:38 +02:00
7ae2bb7635 0.50.18 2026-06-28 06:05:20 +02:00
a75f0662ad Showing alias on scene actor tile. 2026-06-28 06:05:15 +02:00
ffd68d5037 0.50.17 2026-06-28 05:35:53 +02:00
adf9e2334c Fixed actor merge failing if scene has multiple source actors assigned, merging stashed actors. 2026-06-28 05:35:51 +02:00
4125811017 0.50.16 2026-06-19 02:18:05 +02:00
4e8356b072 Fixed actor merge failing if target actor is already assigned to some scenes. Linked ID in actors admin to actor page. 2026-06-19 02:18:02 +02:00
bad116cdc0 Fixed actor edit negative labia boolean ignored by UI. 2026-06-18 00:33:38 +02:00
9eac6871a4 0.50.15 2026-06-18 00:33:26 +02:00
3b694689f3 Fixed actor edit negative boolean ignored by UI. 2026-06-18 00:33:24 +02:00
1604ddaa78 0.50.14 2026-06-18 00:25:06 +02:00
16181923b6 Fixed actor page breaking if no aliases are found. 2026-06-18 00:25:03 +02:00
17 changed files with 353 additions and 95 deletions

View File

@@ -74,7 +74,10 @@
</div> </div>
<span class="label"> <span class="label">
<span class="name ellipsis">{{ actor.name }}</span> <span
class="name ellipsis"
:style="{ 'font-size': `${Math.max(0.9 + Math.min((17 - actor.name.length), 0) * 0.06, 0.65)}rem` }"
>{{ actor.name }}</span>
<img <img
v-if="actor.entity" v-if="actor.entity"
@@ -82,6 +85,13 @@
:src="`/logos/${actor.entity.slug}/favicon_dark.png`" :src="`/logos/${actor.entity.slug}/favicon_dark.png`"
class="favicon" class="favicon"
> >
<Icon
v-if="actor.alias && actor.alias.name !== actor.name"
v-tooltip="`Credited as '${actor.alias.name}'`"
icon="at-sign"
class="alias"
/>
</span> </span>
</div> </div>
</template> </template>
@@ -148,6 +158,7 @@ const favorited = ref(props.actor.stashes.some((actorStash) => actorStash.id ===
} }
.label { .label {
height: 1.75rem;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@@ -161,7 +172,7 @@ const favorited = ref(props.actor.stashes.some((actorStash) => actorStash.id ===
} }
.name { .name {
padding: .35rem .25rem .35rem .5rem; padding: 0 .25rem 0 .5rem;
} }
.favicon { .favicon {
@@ -258,4 +269,11 @@ const favorited = ref(props.actor.stashes.some((actorStash) => actorStash.id ===
height: .75rem; height: .75rem;
margin-left: .25rem; margin-left: .25rem;
} }
.alias {
height: 100%;
fill: var(--glass-weak-20);
padding: 0 .25rem;
cursor: help;
}
</style> </style>

View File

@@ -19,10 +19,16 @@
>{{ avatar.sharpness.toFixed(2) }}</span> >{{ avatar.sharpness.toFixed(2) }}</span>
</span> </span>
<span
:title="`Added ${format(avatar.createdAt, 'yyyy-MM-dd')}, may not reflect photo age`"
class="avatar-date"
>{{ format(avatar.createdAt, '\'\'yy-MM') }}</span>
<a <a
:href="getPath(avatar)" :href="getPath(avatar)"
target="_blank" target="_blank"
class="avatar-zoom" class="avatar-zoom"
@click.stop
> >
<Icon <Icon
icon="search" icon="search"
@@ -32,6 +38,7 @@
</template> </template>
<script setup> <script setup>
import { format } from 'date-fns';
import getPath from '#/src/get-path.js'; import getPath from '#/src/get-path.js';
defineProps({ defineProps({
@@ -89,7 +96,8 @@ defineProps({
} }
.avatar-meta, .avatar-meta,
.avatar-credit { .avatar-credit,
.avatar-date {
position: absolute; position: absolute;
z-index: 10; z-index: 10;
box-sizing: border-box; box-sizing: border-box;
@@ -112,4 +120,10 @@ defineProps({
bottom: .75rem; bottom: .75rem;
left: 0; left: 0;
} }
.avatar-date {
position: absolute;
top: 0;
left: 0;
}
</style> </style>

View File

@@ -146,7 +146,6 @@ const { pageProps } = inject('pageContext');
const { const {
tag: pageTag, tag: pageTag,
actor: pageActor, actor: pageActor,
stash: pageStash,
} = pageProps; } = pageProps;
const search = ref(''); const search = ref('');
@@ -177,7 +176,7 @@ const priorityTags = [
'lesbian', 'lesbian',
]; ];
const isActorTagsAvailable = computed(() => props.actorTags && (props.filters.actors.length > 0 || pageActor) && !pageStash); const isActorTagsAvailable = computed(() => props.actorTags && (props.filters.actors.length > 0 || pageActor));
const groupedTags = computed(() => { const groupedTags = computed(() => {
// can't show actor tags inside stash, because both require a join, and manticore currently only supports one // can't show actor tags inside stash, because both require a join, and manticore currently only supports one
@@ -225,6 +224,8 @@ const groupedTags = computed(() => {
}); });
function toggleTag(tag, combine) { function toggleTag(tag, combine) {
emit('update', 'onlyActorTags', showActorTags.value, false);
if (props.filters.tags.includes(tag.slug)) { if (props.filters.tags.includes(tag.slug)) {
emit('update', 'tags', props.filters.tags.filter((tagId) => tagId !== tag.slug)); emit('update', 'tags', props.filters.tags.filter((tagId) => tagId !== tag.slug));
return; return;

View File

@@ -287,6 +287,7 @@ const filters = ref({
search: urlParsed.search.q, search: urlParsed.search.q,
years: urlParsed.search.years?.split(',').filter(Boolean).map(Number) || [], years: urlParsed.search.years?.split(',').filter(Boolean).map(Number) || [],
tags: urlParsed.search.tags?.split(',').filter(Boolean) || [], tags: urlParsed.search.tags?.split(',').filter(Boolean) || [],
onlyActorTags: Object.hasOwn(urlParsed.search, 'at'),
entity: queryEntity, entity: queryEntity,
actors: queryActors, actors: queryActors,
}); });
@@ -346,6 +347,7 @@ async function search(options = {}) {
years: filters.value.years.join(',') || undefined, years: filters.value.years.join(',') || undefined,
actors: filters.value.actors.map((filterActor) => getActorIdentifier(filterActor)).join(',') || undefined, // don't include page actor ID in query, already a parameter actors: filters.value.actors.map((filterActor) => getActorIdentifier(filterActor)).join(',') || undefined, // don't include page actor ID in query, already a parameter
tags: filters.value.tags.join(',') || undefined, tags: filters.value.tags.join(',') || undefined,
at: (filters.value.tags.length > 0 && filters.value.onlyActorTags) || undefined,
// e: filters.value.entity?.type === 'network' ? `_${filters.value.entity.slug}` : (filters.value.entity?.slug || undefined), // e: filters.value.entity?.type === 'network' ? `_${filters.value.entity.slug}` : (filters.value.entity?.slug || undefined),
e: filters.value.entity ? `${entityPrefixes[filters.value.entity.type]}${filters.value.entity.slug}` : undefined, e: filters.value.entity ? `${entityPrefixes[filters.value.entity.type]}${filters.value.entity.slug}` : undefined,
}, { redirect: false }); }, { redirect: false });
@@ -355,6 +357,7 @@ async function search(options = {}) {
years: filters.value.years.filter(Boolean).join(','), // if we're on an actor page, that actor ID needs to be included years: filters.value.years.filter(Boolean).join(','), // if we're on an actor page, that actor ID needs to be included
actors: [pageActor, ...filters.value.actors].filter(Boolean).map((filterActor) => getActorIdentifier(filterActor)).join(','), // if we're on an actor page, that actor ID needs to be included actors: [pageActor, ...filters.value.actors].filter(Boolean).map((filterActor) => getActorIdentifier(filterActor)).join(','), // if we're on an actor page, that actor ID needs to be included
tags: [pageTag?.slug, ...filters.value.tags].filter(Boolean).join(','), tags: [pageTag?.slug, ...filters.value.tags].filter(Boolean).join(','),
at: !!filters.value.onlyActorTags,
stashId: pageStash?.id, stashId: pageStash?.id,
e: entitySlug, e: entitySlug,
scope: scope.value, scope: scope.value,

4
package-lock.json generated
View File

@@ -1,11 +1,11 @@
{ {
"name": "traxxx-web", "name": "traxxx-web",
"version": "0.50.13", "version": "0.51.4",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"version": "0.50.13", "version": "0.51.4",
"dependencies": { "dependencies": {
"@brillout/json-serializer": "^0.5.8", "@brillout/json-serializer": "^0.5.8",
"@dicebear/collection": "^7.0.5", "@dicebear/collection": "^7.0.5",

View File

@@ -92,7 +92,7 @@
"overrides": { "overrides": {
"vite": "$vite" "vite": "$vite"
}, },
"version": "0.50.13", "version": "0.51.4",
"imports": { "imports": {
"#/*": "./*.js" "#/*": "./*.js"
} }

View File

@@ -466,18 +466,18 @@ const fields = computed(() => [
type: 'augmentation', type: 'augmentation',
note: 'Provide explicit evidence, such as social media posts, visible scarring, or before/after. Avoid "it\'s obvious".', note: 'Provide explicit evidence, such as social media posts, visible scarring, or before/after. Avoid "it\'s obvious".',
value: { value: {
naturalBoobs: actor.value?.naturalBoobs || null, naturalBoobs: actor.value?.naturalBoobs ?? null,
boobsVolume: actor.value?.boobsVolume || null, boobsVolume: actor.value?.boobsVolume || null,
boobsImplant: actor.value?.boobsImplant || null, boobsImplant: actor.value?.boobsImplant || null,
boobsPlacement: actor.value?.boobsPlacement || null, boobsPlacement: actor.value?.boobsPlacement || null,
boobsIncision: actor.value?.boobsIncision || null, boobsIncision: actor.value?.boobsIncision || null,
boobsSurgeon: actor.value?.boobsSurgeon || null, boobsSurgeon: actor.value?.boobsSurgeon || null,
naturalButt: actor.value?.naturalButt || null, naturalButt: actor.value?.naturalButt ?? null,
buttVolume: actor.value?.buttVolume || null, buttVolume: actor.value?.buttVolume || null,
buttImplant: actor.value?.buttImplant || null, buttImplant: actor.value?.buttImplant || null,
naturalLips: actor.value?.naturalLips || null, naturalLips: actor.value?.naturalLips ?? null,
lipsVolume: actor.value?.lipsVolume || null, lipsVolume: actor.value?.lipsVolume || null,
naturalLabia: actor.value?.naturalLabia || null, naturalLabia: actor.value?.naturalLabia ?? null,
}, },
}, },
{ {

View File

@@ -45,7 +45,13 @@
:key="`actor-${actor.id}`" :key="`actor-${actor.id}`"
class="actor" class="actor"
> >
<td class="actor-id ellipsis">{{ actor.id }}</td> <td class="actor-id ellipsis">
<a
:href="`/actor/${actor.id}/${actor.slug}`"
target="_blank"
class="nolink"
>{{ actor.id }}</a>
</td>
<td <td
v-tooltip="actor.entity?.name || 'Global'" v-tooltip="actor.entity?.name || 'Global'"

View File

@@ -140,30 +140,41 @@
</li> </li>
</ul> </ul>
<div class="tags"> <ul class="tags nolist">
<div <li
v-for="actorTags in tags" v-for="tag in tags"
:key="`tags-${actorTags.actor?.slug || 'scene'}`" :key="`tag-${tag.id}`"
class="tags-section" class="tag"
:class="{ 'has-actors': tag.actors.length > 0 }"
> >
<ul class="nolist"> <Link
<li :href="`/tag/${tag.slug}`"
v-if="actorTags.actor" class="tag-name nolink"
class="tags-actor" >{{ tag.name }}</Link>
>{{ actorTags.actor.name }}:</li>
<li <span
v-for="tag in actorTags.tags" v-for="tagActor in tag.actors"
:key="`tag-${tag.id}`" :key="`tagactor-${tagActor.id}`"
v-tooltip="{
content: `Performed by ${tagActor.name}`,
triggers: ['hover', 'click'],
}"
class="tag-frame"
>
<img
v-if="tagActor.avatar"
class="tag-avatar"
:src="getPath(tagActor.avatar, 'thumbnail')"
> >
<Link
:href="`/tag/${tag.slug}`" <Icon
class="tag nolink" v-else
>{{ tag.name }}</Link> icon="star-full"
</li> class="tag-star"
</ul> />
</div> </span>
</div> </li>
</ul>
<div <div
v-if="scene.movies.length > 0 || scene.series.length > 0" v-if="scene.movies.length > 0 || scene.series.length > 0"
@@ -424,6 +435,7 @@ import Cookies from 'js-cookie';
import { formatDate, formatDuration } from '#/utils/format.js'; import { formatDate, formatDuration } from '#/utils/format.js';
import events from '#/src/events.js'; import events from '#/src/events.js';
import processSummaryTemplate from '#/utils/process-summary-template.js'; import processSummaryTemplate from '#/utils/process-summary-template.js';
import getPath from '#/src/get-path.js';
import Banner from '#/components/media/banner.vue'; import Banner from '#/components/media/banner.vue';
import ActorTile from '#/components/actors/tile.vue'; import ActorTile from '#/components/actors/tile.vue';
@@ -450,16 +462,44 @@ const {
const { scene } = pageProps; const { scene } = pageProps;
const tags = [ /*
{ const tags = scene.tags.map((tag) => ({
tags: scene.tags.filter((tag) => tag.actorId === null), ...tag,
actor: null, actor: scene.actors.find((actor) => actor.id === tag.actorId) || null,
}, }));
...scene.actors.map((actor) => ({ */
actor,
tags: scene.tags.filter((tag) => tag.actorId === actor.id), const actorsById = Object.fromEntries(scene.actors.map((actor) => [actor.id, actor]));
})),
].filter((actorTags) => actorTags.tags.length > 0); const tags = Array.from(scene.tags
.reduce((acc, tag) => {
const accTag = acc.get(tag.id);
if (accTag && tag.actorId) {
return acc.set(tag.id, {
...tag,
actors: [...accTag.actors, actorsById[tag.actorId]].toSorted((actorA, actorB) => actorA.name.localeCompare(actorB.name)),
});
}
if (accTag) {
// shouldn't happen, but account for it
return acc;
}
if (tag.actorId) {
return acc.set(tag.id, {
...tag,
actors: [actorsById[tag.actorId]],
});
}
return acc.set(tag.id, {
...tag,
actors: [],
});
}, new Map())
.values());
const showSummaryDialog = ref(false); const showSummaryDialog = ref(false);
@@ -664,7 +704,7 @@ function copySummary() {
.tags { .tags {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: .25rem 1rem; gap: .35rem;
} }
.tags-section { .tags-section {
@@ -684,25 +724,60 @@ function copySummary() {
overflow-x: auto; overflow-x: auto;
.actor { .actor {
width: 10rem; width: 9rem;
flex-shrink: 0; flex-shrink: 0;
} }
} }
.tag { .tag {
padding: .5rem; display: flex;
border-radius: .25rem; border-radius: .25rem;
margin: 0 .25rem .25rem 0;
background: var(--background); background: var(--background);
box-shadow: 0 0 3px var(--shadow-weak-30); box-shadow: 0 0 3px var(--shadow-weak-30);
overflow: hidden;
&:hover {
box-shadow: 0 0 3px var(--shadow-weak-20);
}
}
.tag-name {
display: inline-flex;
align-items: center;
padding: .5rem;
&:hover { &:hover {
color: var(--primary); color: var(--primary);
box-shadow: 0 0 3px var(--shadow-weak-20);
cursor: pointer; cursor: pointer;
} }
} }
.tag-frame {
display: inline-flex;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
position: relative;
overflow: hidden;
&::after {
content: '';
position: absolute;
inset: 0;
box-shadow: inset 0 0 3px var(--shadow-weak-20);
pointer-events: none; /* so it doesn't block hover/click on the image */
}
}
.tag-avatar {
height: 350%;
}
.tag-star {
height: 100%;
fill: var(--primary);
}
.movies, .movies,
.series { .series {
display: grid; display: grid;

View File

@@ -1,7 +1,10 @@
<template> <template>
<div class="page"> <div class="page">
<div class="header"> <div class="header">
<h2 class="title">{{ tag.name }}</h2> <h2
:title="`${tag.name} (#${tag.id})`"
class="title"
>{{ tag.name }}</h2>
<Heart <Heart
domain="tags" domain="tags"

View File

@@ -62,11 +62,22 @@ const keyMap = {
const socialsOrder = ['onlyfans', 'fansly', 'twitter', 'instagram', 'loyalfans', 'manyvids', 'pornhub', 'linktree', null]; const socialsOrder = ['onlyfans', 'fansly', 'twitter', 'instagram', 'loyalfans', 'manyvids', 'pornhub', 'linktree', null];
export function curateActor(actor, context = {}) { export function curateActor(actor, context = {}) {
if (!actor) {
return null;
}
return { return {
id: actor.id, id: actor.id,
slug: actor.slug, slug: actor.slug,
name: actor.name, name: actor.name,
aliases: actor.aliases || [], aliases: actor.aliases || [], // used for profile pages
alias: actor.alias
? {
id: actor.alias.id,
slug: actor.alias.slug,
name: actor.alias.name,
}
: null,
gender: actor.gender, gender: actor.gender,
age: actor.age, age: actor.age,
ethnicity: actor.ethnicity, ethnicity: actor.ethnicity,
@@ -234,7 +245,7 @@ export async function fetchActorsById(actorIds, options = {}, reqUser) {
knex.raw('COALESCE(residence_countries.alias, residence_countries.name) as residence_country_name'), knex.raw('COALESCE(residence_countries.alias, residence_countries.name) as residence_country_name'),
knex.raw('row_to_json(entities) as entity'), knex.raw('row_to_json(entities) as entity'),
knex.raw('row_to_json(sfw_media) as sfw_avatar'), knex.raw('row_to_json(sfw_media) as sfw_avatar'),
knex.raw('json_agg(aliases) as aliases'), knex.raw('json_agg(aliases) filter (where aliases.id is not null) as aliases'),
) )
.leftJoin('actors_meta', 'actors_meta.actor_id', 'actors.id') .leftJoin('actors_meta', 'actors_meta.actor_id', 'actors.id')
.leftJoin('actors as aliases', 'aliases.alias_for', 'actors.id') .leftJoin('actors as aliases', 'aliases.alias_for', 'actors.id')
@@ -598,13 +609,18 @@ export async function mergeActors(targetActorId, sourceActorIds, reqUser) {
const trx = await knex.transaction(); const trx = await knex.transaction();
let mergedProfiles; let mergedProfiles = [];
let mergedScenes; let mergedSceneActors = [];
let existingSceneActors = [];
let duplicateSourceActors = [];
let mergedActorStashes = [];
try { try {
const [existingProfiles] = await Promise.all([ const [existingProfiles, sourceProfiles] = await Promise.all([
trx('actors_profiles') trx('actors_profiles')
.where('actor_id', targetActorId), .where('actor_id', targetActorId),
trx('actors_profiles')
.whereIn('actor_id', sourceActorIds),
trx('actors') trx('actors')
.update('alias_for', targetActorId) .update('alias_for', targetActorId)
.whereIn('id', sourceActorIds) .whereIn('id', sourceActorIds)
@@ -613,19 +629,50 @@ export async function mergeActors(targetActorId, sourceActorIds, reqUser) {
trx('actors_avatars') trx('actors_avatars')
.update('actor_id', targetActorId) .update('actor_id', targetActorId)
.whereIn('actor_id', sourceActorIds), .whereIn('actor_id', sourceActorIds),
trx('stashes_actors')
.update('actor_id', targetActorId)
.whereIn('actor_id', sourceActorIds)
.returning('id'),
]); ]);
// multiple source actors may provide profiles for the same entity, but we can only assign one to the target actor; prefer the newest
const newestSourceProfileMap = Object.fromEntries(sourceProfiles
.toSorted((profileA, profileB) => profileA.updated_at - profileB.updated_at)
.map((profile) => [profile.entity_id, profile.id]));
const duplicateSourceProfiles = sourceProfiles.filter((profile) => newestSourceProfileMap[profile.entity_id] && newestSourceProfileMap[profile.entity_id] !== profile.id);
// assign source actor profiles to target actor, unless a profile for that entity is already present
mergedProfiles = await trx('actors_profiles') mergedProfiles = await trx('actors_profiles')
.update('actor_id', targetActorId) .update('actor_id', targetActorId)
.whereIn('actor_id', sourceActorIds) .whereIn('actor_id', sourceActorIds)
.whereNotIn('entity_id', existingProfiles.map((profile) => profile.entity_id)) .whereNotIn('entity_id', existingProfiles.map((profile) => profile.entity_id))
.whereNotIn('id', duplicateSourceProfiles.map((profile) => profile.id))
.returning('id'); .returning('id');
mergedScenes = await trx('releases_actors') // find releases that have more than one source actor assigned
duplicateSourceActors = await trx('releases_actors')
.select('release_id', knex.raw('array_agg(actor_id) as actor_ids'))
.whereIn('actor_id', sourceActorIds)
.groupBy('release_id')
.having(trx.raw('COUNT(DISTINCT actor_id) > 1'));
if (duplicateSourceActors.length > 0) {
// some scenes have multiple source actors assigned, which will cause a conflict after merging; we will need to remove all but one
await trx('releases_actors')
.whereIn('release_id', duplicateSourceActors.map((sceneActor) => sceneActor.release_id))
.whereIn('actor_id', duplicateSourceActors.flatMap((sceneActor) => sceneActor.actor_ids.slice(1)))
.delete();
}
// find scenes that already have target actor assigned
existingSceneActors = await trx('releases_actors')
.where('actor_id', targetActorId);
// delete release source actors for scenes that already have the target actor assigned
await trx('releases_actors')
.whereIn('release_id', existingSceneActors.map((sceneActor) => sceneActor.release_id))
.whereIn('actor_id', sourceActorIds)
.delete();
// alias release source actors to target actors
mergedSceneActors = await trx('releases_actors')
.update({ .update({
actor_id: targetActorId, actor_id: targetActorId,
alias_id: knex.raw('actor_id'), alias_id: knex.raw('actor_id'),
@@ -633,6 +680,40 @@ export async function mergeActors(targetActorId, sourceActorIds, reqUser) {
.whereIn('actor_id', sourceActorIds) .whereIn('actor_id', sourceActorIds)
.returning('release_id'); .returning('release_id');
const [targetActorStashes, sourceActorStashes] = await Promise.all([
trx('stashes_actors')
.where('actor_id', targetActorId),
trx('stashes_actors')
.whereIn('actor_id', sourceActorIds),
]);
// remove source actors from stashes that already contain target actor
await trx('stashes_actors')
.whereIn('stash_id', targetActorStashes.map((stash) => stash.stash_id))
.whereIn('actor_id', sourceActorIds)
.delete();
// find stashes that have more than one source actor assigned
const duplicateStashActors = await trx('stashes_actors')
.select('stash_id', knex.raw('array_agg(actor_id order by created_at) as actor_ids'))
.whereIn('actor_id', sourceActorStashes.map((actorStash) => actorStash.actor_id))
.groupBy('stash_id')
.having(trx.raw('COUNT(DISTINCT actor_id) > 1'));
if (duplicateStashActors.length > 0) {
// some stashes have multiple source actors assigned, which will cause a conflict after merging; we will need to remove all but one
await trx('stashes_actors')
.whereIn('stash_id', duplicateStashActors.map((actorStash) => actorStash.stash_id))
.whereIn('actor_id', duplicateStashActors.flatMap((actorStash) => actorStash.actor_ids.slice(1)))
.delete();
}
// we update an existing entry instead of creating a new one, so the original stash date is preserved
mergedActorStashes = await trx('stashes_actors')
.update('actor_id', targetActorId)
.whereIn('actor_id', sourceActorIds)
.returning('stash_id');
await trx.commit(); await trx.commit();
} catch (error) { } catch (error) {
await trx.rollback(); await trx.rollback();
@@ -649,14 +730,19 @@ export async function mergeActors(targetActorId, sourceActorIds, reqUser) {
}, { refreshView: false }); }, { refreshView: false });
await Promise.all([ await Promise.all([
syncScenes(mergedScenes.map((scene) => scene.release_id)), syncScenes([
...mergedSceneActors.map((sceneActor) => sceneActor.release_id),
...existingSceneActors.map((sceneActor) => sceneActor.release_id),
...duplicateSourceActors.map((sceneActor) => sceneActor.release_id),
]),
syncActors([targetActorId, ...sourceActorIds]), syncActors([targetActorId, ...sourceActorIds]),
syncStashes('actor', [targetActorId, ...sourceActorIds]), syncStashes('actor', [targetActorId, ...sourceActorIds]),
]); ]);
return { return {
scenes: mergedScenes.length, scenes: mergedSceneActors.length,
profiles: mergedProfiles.length, profiles: mergedProfiles.length,
stashes: mergedActorStashes.length,
}; };
} }

View File

@@ -1,10 +1,12 @@
import initServer from './web/server.js'; import initServer from './web/server.js';
import { initCaches } from './cache.js'; import { initCaches } from './cache.js';
import { initSyncCron } from './sync.js';
async function init() { async function init() {
await initCaches(); await initCaches();
initServer(); initServer();
initSyncCron();
} }
init(); init();

View File

@@ -32,5 +32,6 @@ export function curateMedia(media, context = {}) {
type: context.type || null, type: context.type || null,
sfw: curateMedia(media.sfw_media), sfw: curateMedia(media.sfw_media),
isRestricted: context.isRestricted, isRestricted: context.isRestricted,
createdAt: media.created_at,
}; };
} }

View File

@@ -209,16 +209,19 @@ export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) {
'actors.*', 'actors.*',
knex.raw('row_to_json(avatars) as avatar'), knex.raw('row_to_json(avatars) as avatar'),
knex.raw('row_to_json(sfw_media) as sfw_avatar'), knex.raw('row_to_json(sfw_media) as sfw_avatar'),
knex.raw('row_to_json(aliases) as alias'),
knex.raw('case when aliases.id is not null then json_build_object(\'id\', aliases.id, \'name\', aliases.name, \'slug\', aliases.slug) end as alias'),
'countries.name as birth_country_name', 'countries.name as birth_country_name',
'countries.alias as birth_country_alias', 'countries.alias as birth_country_alias',
'releases_actors.release_id', 'releases_actors.release_id',
) )
.leftJoin('actors', 'actors.id', 'releases_actors.actor_id') .leftJoin('actors', 'actors.id', 'releases_actors.actor_id')
.leftJoin('actors as aliases', 'aliases.id', 'releases_actors.alias_id')
.leftJoin('media as avatars', 'avatars.id', 'actors.avatar_media_id') .leftJoin('media as avatars', 'avatars.id', 'actors.avatar_media_id')
.leftJoin('media as sfw_media', 'sfw_media.id', 'avatars.sfw_media_id') .leftJoin('media as sfw_media', 'sfw_media.id', 'avatars.sfw_media_id')
.leftJoin('countries', 'countries.alpha2', 'actors.birth_country_alpha2') .leftJoin('countries', 'countries.alpha2', 'actors.birth_country_alpha2')
.whereIn('release_id', sceneIds) .whereIn('release_id', sceneIds)
.groupBy('actors.id', 'releases_actors.release_id', 'avatars.id', 'countries.name', 'countries.alias', 'sfw_media.id'), .groupBy('actors.id', 'aliases.id', 'releases_actors.release_id', 'avatars.id', 'countries.name', 'countries.alias', 'sfw_media.id'),
directors: knex('releases_directors') directors: knex('releases_directors')
.whereIn('release_id', sceneIds) .whereIn('release_id', sceneIds)
.leftJoin('actors as directors', 'directors.id', 'releases_directors.director_id'), .leftJoin('actors as directors', 'directors.id', 'releases_directors.director_id'),
@@ -415,6 +418,20 @@ function curateFacet(results, field) {
|| []; || [];
} }
const packN = 100_000;
function mergePackedTags(tags) {
const mergedCounts = tags.reduce((merged, tag) => {
const tagId = tag.key % packN;
merged.set(tagId, (merged.get(tagId) ?? 0) + tag.doc_count);
return merged;
}, new Map());
return Array.from(mergedCounts.entries(), ([key, count]) => ({ key, doc_count: count }));
}
async function queryManticoreSql(filters, options, _reqUser) { async function queryManticoreSql(filters, options, _reqUser) {
const aggSize = config.database.manticore.maxAggregateSize; const aggSize = config.database.manticore.maxAggregateSize;
@@ -437,7 +454,6 @@ async function queryManticoreSql(filters, options, _reqUser) {
:yearsFacet: :yearsFacet:
:actorsFacet: :actorsFacet:
:tagsFacet: :tagsFacet:
:actorTagsFacet:
:channelsFacet: :channelsFacet:
:studiosFacet:; :studiosFacet:;
show meta; show meta;
@@ -470,11 +486,6 @@ async function queryManticoreSql(filters, options, _reqUser) {
year(scenes.effective_date) as effective_year, year(scenes.effective_date) as effective_year,
weight() as _score weight() as _score
`)); `));
// manticore only supports one joined table, so we can't use it inside stashes
builder
.leftJoin('scenes_tags', 'scenes_tags.scene_id', 'scenes_.id')
.groupBy('scenes.id');
} }
if (filters.query) { if (filters.query) {
@@ -487,7 +498,17 @@ async function queryManticoreSql(filters, options, _reqUser) {
} }
filters.tagIds?.forEach((tagId) => { filters.tagIds?.forEach((tagId) => {
builder.where('any(tag_ids)', tagId); if (filters.onlyActorTags) {
builder.where((whereBuilder) => {
whereBuilder.where('any(assigned_tag_ids)', tagId);
filters.actorIds?.forEach((actorId) => {
whereBuilder.orWhere('any(assigned_tag_ids)', actorId * 1_000_00 + tagId);
});
});
} else {
builder.where('any(tag_ids)', tagId);
}
}); });
if (filters.notTagIds) { if (filters.notTagIds) {
@@ -530,12 +551,6 @@ async function queryManticoreSql(filters, options, _reqUser) {
builder.where('scenes.is_showcased', filters.isShowcased); builder.where('scenes.is_showcased', filters.isShowcased);
} }
/*
if (filters.isShowcased) {
builder.where('scenes.date', '>', 0);
}
*/
if (options.dedupe) { if (options.dedupe) {
builder.where('scenes.dupe_index', '<', 2); builder.where('scenes.dupe_index', '<', 2);
} }
@@ -580,11 +595,7 @@ async function queryManticoreSql(filters, options, _reqUser) {
// option threads=1 fixes actors, but drastically slows down performance, wait for fix // option threads=1 fixes actors, but drastically slows down performance, wait for fix
yearsFacet: options.aggregateYears ? knex.raw('facet effective_year as years_facet order by effective_year desc limit ?', [aggSize]) : null, yearsFacet: options.aggregateYears ? knex.raw('facet effective_year as years_facet order by effective_year desc limit ?', [aggSize]) : null,
actorsFacet: options.aggregateActors ? knex.raw('facet scenes.actor_ids as actors_facet distinct id order by count(*) desc limit ?', [aggSize]) : null, actorsFacet: options.aggregateActors ? knex.raw('facet scenes.actor_ids as actors_facet distinct id order by count(*) desc limit ?', [aggSize]) : null,
// don't facet tags associated to other actors, actor ID 0 means global tagsFacet: options.aggregateTags ? knex.raw('facet scenes.assigned_tag_ids as tags_facet distinct id order by count(*) desc limit ?', [aggSize]) : null,
tagsFacet: options.aggregateTags ? knex.raw('facet scenes.tag_ids as tags_facet distinct id order by count(*) desc limit ?', [aggSize]) : null,
actorTagsFacet: options.aggregateTags && !filters.stashId // eslint-disable-line no-nested-ternary
? knex.raw(`facet IF(IN(scenes_tags.actor_id, ${[0, ...filters?.actorIds || []]}), scenes_tags.tag_id, 0) as actor_tags_facet distinct id order by count(*) desc limit ?`, [aggSize])
: null,
channelsFacet: options.aggregateChannels ? knex.raw('facet scenes.channel_id as channels_facet distinct id order by count(*) desc limit ?', [aggSize]) : null, channelsFacet: options.aggregateChannels ? knex.raw('facet scenes.channel_id as channels_facet distinct id order by count(*) desc limit ?', [aggSize]) : null,
studiosFacet: options.aggregateChannels ? knex.raw('facet scenes.studio_id as studios_facet distinct id order by count(*) desc limit ?', [aggSize]) : null, studiosFacet: options.aggregateChannels ? knex.raw('facet scenes.studio_id as studios_facet distinct id order by count(*) desc limit ?', [aggSize]) : null,
maxMatches: config.database.manticore.maxMatches, maxMatches: config.database.manticore.maxMatches,
@@ -607,10 +618,26 @@ async function queryManticoreSql(filters, options, _reqUser) {
const years = curateFacet(results, 'years_facet'); const years = curateFacet(results, 'years_facet');
const actorIds = curateFacet(results, 'actors_facet'); const actorIds = curateFacet(results, 'actors_facet');
const tagIds = curateFacet(results, 'tags_facet'); const tagIds = curateFacet(results, 'tags_facet');
const actorTagIds = curateFacet(results, 'actor_tags_facet');
const channelIds = curateFacet(results, 'channels_facet'); const channelIds = curateFacet(results, 'channels_facet');
const studioIds = curateFacet(results, 'studios_facet'); const studioIds = curateFacet(results, 'studios_facet');
const allTagIds = mergePackedTags(tagIds);
const actorTagIds = mergePackedTags(tagIds.filter((tag) => {
if (tag.key < packN || !filters?.actorIds.length) {
// global
return true;
}
const tagActorId = Math.floor(tag.key / packN);
if (filters.actorIds.includes(tagActorId)) {
return true;
}
return false;
}));
const total = Number(results.at(-1).data.find((entry) => entry.Variable_name === 'total_found')?.Value) || 0; const total = Number(results.at(-1).data.find((entry) => entry.Variable_name === 'total_found')?.Value) || 0;
return { return {
@@ -619,7 +646,7 @@ async function queryManticoreSql(filters, options, _reqUser) {
aggregations: { aggregations: {
years, years,
actorIds, actorIds,
tagIds, tagIds: allTagIds,
actorTagIds, actorTagIds,
channelIds, channelIds,
studioIds, studioIds,

View File

@@ -114,7 +114,8 @@ export async function syncManticoreScenes(sceneIds) {
studios.name as studio_name, studios.name as studio_name,
grandparents.id as parent_network_id, grandparents.id as parent_network_id,
COALESCE(JSON_AGG(DISTINCT (actors.id, actors.name)) FILTER (WHERE actors.id IS NOT NULL), '[]') as actors, COALESCE(JSON_AGG(DISTINCT (actors.id, actors.name)) FILTER (WHERE actors.id IS NOT NULL), '[]') as actors,
COALESCE(JSON_AGG(DISTINCT (tags.id, tags.name, tags.priority, tags_aliases.name)) FILTER (WHERE tags.id IS NOT NULL), '[]') as tags, COALESCE(JSON_AGG(DISTINCT (actors_aliases.id, actors_aliases.name)) FILTER (WHERE actors_aliases.id IS NOT NULL), '[]') as actors_aliases,
COALESCE(JSON_AGG(DISTINCT (tags.id, tags.name, tags.priority, tags_aliases.name, local_tags.actor_id)) FILTER (WHERE tags.id IS NOT NULL), '[]') as tags,
COALESCE(JSON_AGG(DISTINCT (movies.id, movies.title)) FILTER (WHERE movies.id IS NOT NULL), '[]') as movies, COALESCE(JSON_AGG(DISTINCT (movies.id, movies.title)) FILTER (WHERE movies.id IS NOT NULL), '[]') as movies,
COALESCE(JSON_AGG(DISTINCT (series.id, series.title)) FILTER (WHERE series.id IS NOT NULL), '[]') as series, COALESCE(JSON_AGG(DISTINCT (series.id, series.title)) FILTER (WHERE series.id IS NOT NULL), '[]') as series,
studios.showcased IS NOT false studios.showcased IS NOT false
@@ -135,6 +136,7 @@ export async function syncManticoreScenes(sceneIds) {
LEFT JOIN releases_tags AS local_tags ON local_tags.release_id = releases.id LEFT JOIN releases_tags AS local_tags ON local_tags.release_id = releases.id
LEFT JOIN actors ON local_actors.actor_id = actors.id LEFT JOIN actors ON local_actors.actor_id = actors.id
LEFT JOIN actors AS directors ON local_directors.director_id = directors.id LEFT JOIN actors AS directors ON local_directors.director_id = directors.id
LEFT JOIN actors AS actors_aliases ON actors_aliases.alias_for = actors.id
LEFT JOIN tags ON local_tags.tag_id = tags.id LEFT JOIN tags ON local_tags.tag_id = tags.id
LEFT JOIN tags as tags_aliases ON local_tags.tag_id = tags_aliases.alias_for AND tags_aliases.secondary = true LEFT JOIN tags as tags_aliases ON local_tags.tag_id = tags_aliases.alias_for AND tags_aliases.secondary = true
LEFT JOIN movies_scenes ON movies_scenes.scene_id = releases.id LEFT JOIN movies_scenes ON movies_scenes.scene_id = releases.id
@@ -185,6 +187,18 @@ export async function syncManticoreScenes(sceneIds) {
const flatTags = scene.tags.filter((tag) => tag.f3 > 6).flatMap((tag) => [tag.f2].concat(tag.f4)).filter(Boolean); // only make top tags searchable to minimize cluttered results const flatTags = scene.tags.filter((tag) => tag.f3 > 6).flatMap((tag) => [tag.f2].concat(tag.f4)).filter(Boolean); // only make top tags searchable to minimize cluttered results
const filteredTitle = filterTitle(scene.title, [...flatActors, ...flatTags]); const filteredTitle = filterTitle(scene.title, [...flatActors, ...flatTags]);
// use decimal packing with 5-decimal pad to allow for actor-specific tags, i.e. actor 135 tag 5 = 13500005
// all global tags are necessarily < 10,000, all tags for actor 135 are >= 13500000 and <= 13599999
// f1 = tag ID, f5 = actor ID
const assignedTagIds = scene.tags.map((tag) => (tag.f5 === null ? tag.f1 : tag.f5 * 1_000_00 + tag.f1));
/*
if (sceneId === '187734') {
console.log(scene, assignedTagIds);
throw new Error('ABORT');
}
*/
return { return {
replace: { replace: {
index: 'scenes', index: 'scenes',
@@ -207,9 +221,10 @@ export async function syncManticoreScenes(sceneIds) {
studio_slug: scene.studio_slug || undefined, studio_slug: scene.studio_slug || undefined,
studio_name: scene.studio_name || undefined, studio_name: scene.studio_name || undefined,
entity_ids: [scene.channel_id, scene.network_id, scene.parent_network_id, scene.studio_id].filter(Boolean), // manticore does not support OR, this allows IN entity_ids: [scene.channel_id, scene.network_id, scene.parent_network_id, scene.studio_id].filter(Boolean), // manticore does not support OR, this allows IN
actor_ids: scene.actors.map((actor) => actor.f1), actor_ids: scene.actors.map((actor) => actor.f1), // don't include aliases in ID or they would show up in filters
actors: scene.actors.map((actor) => actor.f2).join(), actors: Array.from(new Set([...scene.actors.map((actor) => actor.f2), ...scene.actors_aliases.map((actor) => actor.f2)])).join(),
tag_ids: scene.tags.map((tag) => tag.f1), tag_ids: Array.from(new Set(scene.tags.map((tag) => tag.f1))),
assigned_tag_ids: assignedTagIds,
tags: flatTags.join(' '), // only make top tags searchable to minimize cluttered results tags: flatTags.join(' '), // only make top tags searchable to minimize cluttered results
movie_ids: scene.movies.map((movie) => movie.f1), movie_ids: scene.movies.map((movie) => movie.f1),
movies: scene.movies.map((movie) => movie.f2).join(' '), movies: scene.movies.map((movie) => movie.f2).join(' '),
@@ -470,11 +485,13 @@ export async function syncQueue() {
logger[process.tasks > 0 ? 'info' : 'verbose'](`Processed ${tasks.length} sync items`); logger[process.tasks > 0 ? 'info' : 'verbose'](`Processed ${tasks.length} sync items`);
} }
CronJob.from({ export function initSyncCron() {
cronTime: config.sync.crontab, CronJob.from({
async onTick() { cronTime: config.sync.crontab,
syncQueue(); async onTick() {
}, syncQueue();
start: config.sync.enabled, },
runOnInit: true, start: config.sync.enabled,
}); runOnInit: true,
});
}

View File

@@ -44,6 +44,7 @@ export async function curateScenesQuery(query) {
notActorIds: splitActors.filter((actor) => actor.charAt(0) === '!').map((identifier) => parseActorIdentifier(identifier.slice(1))?.id).filter(Boolean), notActorIds: splitActors.filter((actor) => actor.charAt(0) === '!').map((identifier) => parseActorIdentifier(identifier.slice(1))?.id).filter(Boolean),
tagIds, tagIds,
notTagIds: notTagIds.filter((tagId) => !tagIds.includes(tagId)), // included tags get priority over excluded tags notTagIds: notTagIds.filter((tagId) => !tagIds.includes(tagId)), // included tags get priority over excluded tags
onlyActorTags: !!query.at,
entityId, entityId,
notEntityIds, notEntityIds,
movieId: Number(query.movieId) || null, movieId: Number(query.movieId) || null,

View File

@@ -25,6 +25,7 @@ async function init() {
actor_ids multi, actor_ids multi,
actors text, actors text,
tag_ids multi, tag_ids multi,
assigned_tag_ids multi64,
tags text, tags text,
movie_ids multi, movie_ids multi,
movies text, movies text,
@@ -41,12 +42,15 @@ async function init() {
)`); )`);
await utilsApi.sql('drop table if exists scenes_tags'); await utilsApi.sql('drop table if exists scenes_tags');
/* legacy, using packed decimal keys now
await utilsApi.sql(`create table scenes_tags ( await utilsApi.sql(`create table scenes_tags (
id int, id int,
scene_id int, scene_id int,
tag_id int, tag_id int,
actor_id int actor_id int
)`); )`);
*/
console.log('Recreated scenes tables, syncing scenes...'); console.log('Recreated scenes tables, syncing scenes...');