Compare commits

...

17 Commits

Author SHA1 Message Date
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
11 changed files with 153 additions and 31 deletions

View File

@@ -82,6 +82,13 @@
:src="`/logos/${actor.entity.slug}/favicon_dark.png`"
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>
</div>
</template>
@@ -258,4 +265,11 @@ const favorited = ref(props.actor.stashes.some((actorStash) => actorStash.id ===
height: .75rem;
margin-left: .25rem;
}
.alias {
height: 100%;
fill: var(--glass-weak-20);
padding: 0 .25rem;
cursor: help;
}
</style>

View File

@@ -19,10 +19,16 @@
>{{ avatar.sharpness.toFixed(2) }}</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
:href="getPath(avatar)"
target="_blank"
class="avatar-zoom"
@click.stop
>
<Icon
icon="search"
@@ -32,6 +38,7 @@
</template>
<script setup>
import { format } from 'date-fns';
import getPath from '#/src/get-path.js';
defineProps({
@@ -89,7 +96,8 @@ defineProps({
}
.avatar-meta,
.avatar-credit {
.avatar-credit,
.avatar-date {
position: absolute;
z-index: 10;
box-sizing: border-box;
@@ -112,4 +120,10 @@ defineProps({
bottom: .75rem;
left: 0;
}
.avatar-date {
position: absolute;
top: 0;
left: 0;
}
</style>

4
package-lock.json generated
View File

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

View File

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

View File

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

View File

@@ -62,11 +62,22 @@ const keyMap = {
const socialsOrder = ['onlyfans', 'fansly', 'twitter', 'instagram', 'loyalfans', 'manyvids', 'pornhub', 'linktree', null];
export function curateActor(actor, context = {}) {
if (!actor) {
return null;
}
return {
id: actor.id,
slug: actor.slug,
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,
age: actor.age,
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('row_to_json(entities) as entity'),
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 as aliases', 'aliases.alias_for', 'actors.id')
@@ -598,8 +609,11 @@ export async function mergeActors(targetActorId, sourceActorIds, reqUser) {
const trx = await knex.transaction();
let mergedProfiles;
let mergedScenes;
let mergedProfiles = [];
let mergedSceneActors = [];
let existingSceneActors = [];
let duplicateSourceActors = [];
let mergedActorStashes = [];
try {
const [existingProfiles] = await Promise.all([
@@ -613,19 +627,42 @@ export async function mergeActors(targetActorId, sourceActorIds, reqUser) {
trx('actors_avatars')
.update('actor_id', targetActorId)
.whereIn('actor_id', sourceActorIds),
trx('stashes_actors')
.update('actor_id', targetActorId)
.whereIn('actor_id', sourceActorIds)
.returning('id'),
]);
// assign source actor profiles to target actor, unless a profile for that entity is already present
mergedProfiles = await trx('actors_profiles')
.update('actor_id', targetActorId)
.whereIn('actor_id', sourceActorIds)
.whereNotIn('entity_id', existingProfiles.map((profile) => profile.entity_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({
actor_id: targetActorId,
alias_id: knex.raw('actor_id'),
@@ -633,6 +670,40 @@ export async function mergeActors(targetActorId, sourceActorIds, reqUser) {
.whereIn('actor_id', sourceActorIds)
.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();
} catch (error) {
await trx.rollback();
@@ -649,14 +720,19 @@ export async function mergeActors(targetActorId, sourceActorIds, reqUser) {
}, { refreshView: false });
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]),
syncStashes('actor', [targetActorId, ...sourceActorIds]),
]);
return {
scenes: mergedScenes.length,
scenes: mergedSceneActors.length,
profiles: mergedProfiles.length,
stashes: mergedActorStashes.length,
};
}

View File

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

View File

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

View File

@@ -209,16 +209,19 @@ export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) {
'actors.*',
knex.raw('row_to_json(avatars) as 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.alias as birth_country_alias',
'releases_actors.release_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 sfw_media', 'sfw_media.id', 'avatars.sfw_media_id')
.leftJoin('countries', 'countries.alpha2', 'actors.birth_country_alpha2')
.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')
.whereIn('release_id', sceneIds)
.leftJoin('actors as directors', 'directors.id', 'releases_directors.director_id'),

View File

@@ -114,6 +114,7 @@ export async function syncManticoreScenes(sceneIds) {
studios.name as studio_name,
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_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)) 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 (series.id, series.title)) FILTER (WHERE series.id IS NOT NULL), '[]') as series,
@@ -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 actors ON local_actors.actor_id = actors.id
LEFT JOIN actors AS directors ON local_directors.director_id = directors.id
LEFT JOIN actors AS actors_aliases ON actors_aliases.id = local_actors.alias_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 movies_scenes ON movies_scenes.scene_id = releases.id
@@ -185,6 +187,8 @@ 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 filteredTitle = filterTitle(scene.title, [...flatActors, ...flatTags]);
// TODO: reconsider how direct vs indirect tags are stored and searched
return {
replace: {
index: 'scenes',
@@ -207,8 +211,8 @@ export async function syncManticoreScenes(sceneIds) {
studio_slug: scene.studio_slug || 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
actor_ids: scene.actors.map((actor) => actor.f1),
actors: scene.actors.map((actor) => actor.f2).join(),
actor_ids: scene.actors.map((actor) => actor.f1), // don't include aliases in ID or they would show up in filters
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),
tags: flatTags.join(' '), // only make top tags searchable to minimize cluttered results
movie_ids: scene.movies.map((movie) => movie.f1),
@@ -470,11 +474,13 @@ export async function syncQueue() {
logger[process.tasks > 0 ? 'info' : 'verbose'](`Processed ${tasks.length} sync items`);
}
CronJob.from({
cronTime: config.sync.crontab,
async onTick() {
syncQueue();
},
start: config.sync.enabled,
runOnInit: true,
});
export function initSyncCron() {
CronJob.from({
cronTime: config.sync.crontab,
async onTick() {
syncQueue();
},
start: config.sync.enabled,
runOnInit: true,
});
}