Compare commits

...

36 Commits

Author SHA1 Message Date
aa3adbe634 Changed bio location phrasing for deceased actors. 2026-03-30 22:15:40 +02:00
59a700c2f3 0.49.6 2026-03-30 17:00:19 +02:00
18f5a6f476 Fixed age of passing in actor tile. 2026-03-30 17:00:17 +02:00
63a178ca57 0.49.5 2026-03-28 16:29:59 +01:00
0ae949a616 Enabled extreme insertion tag filter by default. 2026-03-28 16:29:57 +01:00
edc9720623 Added RTA logo file to repo. 2026-03-27 05:44:15 +01:00
bbc3fbb0a5 0.49.4 2026-03-27 04:26:19 +01:00
1fc468efac Added RTA Restrict To Adults marker. 2026-03-27 04:26:16 +01:00
143c415797 0.49.3 2026-03-23 17:26:25 +01:00
e79a4d48e1 Removed handle from bio socials to save space. 2026-03-23 17:26:23 +01:00
343325440e 0.49.2 2026-03-22 06:24:07 +01:00
5c018892d3 Fixed revision tag modified highlight for actor association change. 2026-03-22 06:24:05 +01:00
be61293cbe 0.49.1 2026-03-22 06:14:46 +01:00
e493194ce1 Added scene revision tag fix tool. 2026-03-22 06:14:41 +01:00
b61631c33c Fixed scene actor tag revision display. 2026-03-22 05:53:13 +01:00
fa65da75bc Fixed scene tag delta storage format for actor associations. 2026-03-22 05:39:22 +01:00
a4468f18dc 0.49.0 2026-03-22 04:50:51 +01:00
fea28b71ba Restored actor tag filtering with performance fixes. 2026-03-22 04:50:35 +01:00
884ad891f3 0.48.2 2026-03-22 02:09:22 +01:00
058161f798 Patched manticore death queries. 2026-03-22 02:09:20 +01:00
aa68748817 0.48.1 2026-03-20 01:42:02 +01:00
928857596f Fixed fallback watch URL not generated for scenes without URL or network. 2026-03-20 01:42:00 +01:00
e6919a4283 0.48.0 2026-03-17 01:43:51 +01:00
f7993a9108 Added delete option to scene edits. 2026-03-17 01:43:49 +01:00
134664095a 0.47.13 2026-03-16 05:03:19 +01:00
7b2495cef5 Added spitroast and orgy to priority tags, reordered. 2026-03-16 05:03:16 +01:00
27ce8b0ceb 0.47.12 2026-03-13 04:52:24 +01:00
0e5724533f Removed Manticore tools to prevent confusion. 2026-03-13 04:52:22 +01:00
cea58d12ff 0.47.11 2026-03-13 04:45:10 +01:00
25034e7a4b Added manticore table recreation to stash sync. 2026-03-13 04:45:07 +01:00
cd4a7ce9c8 0.47.10 2026-03-10 22:12:41 +01:00
2229255ff4 Disabled actor tags for performance evaluation. 2026-03-10 22:12:39 +01:00
299dbe3239 0.47.9 2026-03-07 02:30:53 +01:00
deced84c59 Added function for scene facet composition. 2026-03-07 02:30:51 +01:00
0150ae8d1c 0.47.8 2026-03-07 02:09:06 +01:00
6877ee75ed Added toggle to select actor tags or all tags in filters. 2026-03-07 02:09:04 +01:00
26 changed files with 438 additions and 648426 deletions

View File

@@ -19,6 +19,9 @@
<meta name="viewport" content="width=device-width,height=device-height,initial-scale=1.0,interactive-widget=resizes-content" />
<!-- RTA restricted to adults label -->
<meta name="RATING" content="RTA-5042-1996-1400-1577-RTA" />
<title>traxxx - Consent</title>
<style>
:root {
@@ -156,6 +159,12 @@
text-decoration: underline;
}
.rta {
position: fixed;
bottom: .5rem;
right: .5rem;
}
@media(max-width: 800px) {
.heading {
font-size: 1.25rem;
@@ -219,5 +228,12 @@
</a>
</div>
</div>
<img
src="/img/rta.gif"
alt="RTA Restricted To Adults"
title="RTA Restricted To Adults"
class="rta"
>
</body>
</html>

View File

@@ -119,12 +119,22 @@
.button-cancel {
background: none;
color: var(--glass);
color: var(--error);
font-weight: normal;
box-shadow: none;
.icon {
fill: var(--error);
}
&:hover:not(:disabled) {
color: var(--error);
color: var(--text-light);
background: var(--error);
cursor: pointer;
.icon {
fill: var(--text-light);
}
}
&:disabled {

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generated by IcoMoon.io -->
<svg
version="1.1"
width="16"
height="16"
viewBox="0 0 16 16"
id="svg2"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs2" />
<path
id="path2"
d="M 7 0 C 4 0 4 2.015 4 4.5 C 4 6.048 4.898 7.5957969 6 8.2167969 L 6 9.0410156 C 2.608 9.3180156 0 10.985 0 13 L 4.9726562 13 C 4.6689986 12.449922 4.7486357 11.731833 5.2109375 11.269531 L 8 8.4804688 L 8 8.2167969 C 9.102 7.5957969 10 6.048 10 4.5 C 10 2.015 10 0 7 0 z M 9 11.509766 L 8.2167969 12.292969 L 8.9238281 13 L 9 13 L 9 11.509766 z " />
<g
id="g2"
transform="matrix(0.51568847,0,0,0.51568847,5.8471254,8.4255678)">
<path
d="m 18.938,-1 h -6 c -0.412,0 -0.989,0.239 -1.28,0.53 L 4.219,6.969 c -0.292,0.292 -0.292,0.769 0,1.061 l 6.439,6.439 c 0.292,0.292 0.769,0.292 1.061,0 L 19.158,7.03 c 0.292,-0.292 0.53,-0.868 0.53,-1.28 v -6 c 0,-0.412 -0.337,-0.75 -0.75,-0.75 z m -3.75,6 c -0.828,0 -1.5,-0.672 -1.5,-1.5 0,-0.828 0.672,-1.5 1.5,-1.5 0.828,0 1.5,0.672 1.5,1.5 0,0.828 -0.672,1.5 -1.5,1.5 z"
id="path1-5" />
<path
d="m 1.688,7.5 8.5,-8.5 h -1.25 c -0.412,0 -0.989,0.239 -1.28,0.53 L 0.219,6.969 c -0.292,0.292 -0.292,0.769 0,1.061 l 6.439,6.439 c 0.292,0.292 0.769,0.292 1.061,0 l 0.47,-0.47 -6.5,-6.5 z"
id="path2-3" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

2
common

Submodule common updated: ec4b15ce33...e4d6ff6ad1

View File

@@ -105,7 +105,7 @@
class="bio-item residence"
:class="{ hideable: !!actor.origin }"
>
<dfn class="bio-label"><Icon icon="location" />Lives in</dfn>
<dfn class="bio-label"><Icon icon="location" />{{ actor.dateOfDeath ? 'Lived' : 'Lives' }} in</dfn>
<span>
<span
@@ -312,10 +312,10 @@
<a
v-for="social in socials"
:key="`social-${social.id}`"
v-tooltip="social.platform ? `${social.platform} ${env.socials.prefix[social.platform] || env.socials.prefix.default}${social.handle}` : social.url"
:href="getSocialUrl(social)"
target="_blank"
rel="noopener"
:title="social.platform || social.url"
class="social ellipsis"
>
<Icon
@@ -339,7 +339,9 @@
:class="`icon-social icon-${social.platform} icon-generic`"
/>
<!--
<template v-if="social.platform">{{ env.socials.prefix[social.platform] || env.socials.prefix.default }}</template>{{ social.handle }}
-->
</a>
</ul>
</div>
@@ -498,7 +500,7 @@ function getSocialUrl(social) {
return null;
}
const socials = props.actor.socials.map((social) => ({
const socials = props.actor.socials.slice(0, 10).map((social) => ({
...social,
handle: social.url
? new URL(social.url).hostname
@@ -735,19 +737,23 @@ const socials = props.actor.socials.map((social) => ({
}
.socials {
display: flex;
flex-wrap: wrap;
/*
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
grid-gap: 0 0;
overflow: hidden;
*/
gap: .25rem;
padding: 0;
}
.social {
display: flex;
height: 2rem;
display: inline-flex;
align-items: center;
padding: .1rem .5rem;
justify-content: center;
padding: .75rem .75rem;
border-radius: .25rem;
color: inherit;
text-decoration: none;
@@ -755,10 +761,6 @@ const socials = props.actor.socials.map((social) => ({
font-weight: normal;
background: var(--highlight-weak-40);
.icon {
margin-right: .5rem;
}
.icon-generic {
fill: var(--highlight);
}

View File

@@ -41,7 +41,7 @@
<span
v-if="actor.ageAtDeath"
:title="`Passed ${formatDate(actor.ageAtDeath, 'MMMM d, yyyy')}`"
:title="`Passed ${formatDate(actor.dateOfDeath, 'MMMM d, yyyy')}`"
class="age age-death"
>{{ actor.ageAtDeath }}</span>

View File

@@ -266,7 +266,7 @@ const expanded = ref(new Set());
const mappedKeys = {
actors: actorsById,
tags: tagsById,
// tags: tagsById,
movies: moviesById,
};
@@ -292,6 +292,16 @@ const curatedRevisions = computed(() => revisions.value.map((revision) => {
}))];
}
if (key === 'tags') {
return [key, value.map((tag) => ({
id: tag.id,
name: tag.actorId
? `${actorsById.value[tag.actorId]?.name}: ${tagsById.value[tag.id]?.name}`
: tagsById.value[tag.id]?.name,
modified: revision.deltas.some((delta) => delta.key === key && !delta.value.some((deltaTag) => deltaTag.id === tag.id && (!Object.hasOwn(tag, 'actorId') || deltaTag.actorId === tag.actorId))),
}))];
}
if (key === 'socials') {
// new socials don't have IDs yet, so we need to compare the values
return [key, value.map((item) => ({
@@ -323,6 +333,19 @@ const curatedRevisions = computed(() => revisions.value.map((revision) => {
};
}
if (delta.key === 'tags') {
return {
...delta,
value: delta.value.map((tag) => ({
id: tag.id,
name: tag.actorId
? `${actorsById.value[tag.actorId]?.name}: ${tagsById.value[tag.id]?.name}`
: tagsById.value[tag.id]?.name,
modified: !revision.base[delta.key].some((baseTag) => baseTag.id === tag.id && (!Object.hasOwn(baseTag, 'actorId') || baseTag.actorId === tag.actorId)),
})),
};
}
if (delta.key === 'socials') {
// new socials don't have IDs yet, so we need to compare the values
return {
@@ -378,6 +401,8 @@ async function reviewRevision(revision, isApproved) {
await post(`/revisions/${domain}/${revision.id}/reviews`, {
isApproved,
feedback: feedbacks.value[revision.id],
}, {
appendErrorMessage: true,
});
const updatedRevision = await get(`/revisions/${domain}/${revision.id}`, {

View File

@@ -12,14 +12,32 @@
<Icon icon="search" />
</label>
<template v-if="isActorTagsAvailable">
<div
v-show="showActorTags"
v-tooltip="'Tags relevant to the selected actors'"
class="filter-sort order noselect"
@click="showActorTags = false"
>
<Icon icon="user-tags" />
</div>
<div
v-show="!showActorTags"
v-tooltip="'All tags'"
class="filter-sort order noselect"
@click="showActorTags = true"
>
<Icon icon="price-tags" />
</div>
</template>
<div
v-show="order === 'priority'"
class="filter-sort order noselect"
@click="order = 'count'"
>
<Icon
icon="star"
/>
<Icon icon="star" />
</div>
<div
@@ -115,41 +133,60 @@ const props = defineProps({
type: Array,
default: () => [],
},
actorTags: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['update']);
const { pageProps } = inject('pageContext');
const {
tag: pageTag,
actor: pageActor,
stash: pageStash,
} = pageProps;
const search = ref('');
const searchRegexp = computed(() => new RegExp(search.value, 'i'));
const order = ref('priority');
const { pageProps } = inject('pageContext');
const { tag: pageTag } = pageProps;
const showActorTags = ref(!!pageActor);
const priorityTags = [
'anal',
'dp',
'threesome',
'gangbang',
'blowbang',
'transsexual',
'orgy',
'airtight',
'dp',
'dap',
'dvp',
'triple-penetration',
'tap',
'tvp',
'transsexual',
'spitroast',
'mfm',
'fmf',
'threesome',
'bdsm',
'deepthroat',
'blowjob',
'lesbian',
];
const isActorTagsAvailable = computed(() => props.actorTags && (props.filters.actors.length > 0 || pageActor) && !pageStash);
const groupedTags = computed(() => {
const selected = props.tags.filter((tag) => props.filters.tags.includes(tag.slug));
const filtered = props.tags.filter((tag) => !props.filters.tags.includes(tag.slug)
// can't show actor tags inside stash, because both require a join, and manticore currently only supports one
const tags = showActorTags.value && isActorTagsAvailable.value
? props.actorTags
: props.tags;
const selected = tags.filter((tag) => props.filters.tags.includes(tag.slug));
const filtered = tags.filter((tag) => !props.filters.tags.includes(tag.slug)
&& tag.id !== pageTag?.id
&& searchRegexp.value.test(tag.name));

View File

@@ -34,6 +34,7 @@
<TagsFilter
:filters="filters"
:tags="aggTags"
:actor-tags="aggActorTags"
@update="updateFilter"
/>
@@ -263,6 +264,7 @@ const scenes = ref(pageProps.scenes);
const aggYears = ref(pageProps.aggYears || []);
const aggActors = ref(pageProps.aggActors || []);
const aggTags = ref(pageProps.aggTags || []);
const aggActorTags = ref(pageProps.aggActorTags || []);
const aggChannels = ref(pageProps.aggChannels || []);
const currentPage = ref(Number(routeParams.page));
@@ -363,6 +365,7 @@ async function search(options = {}) {
aggYears.value = res.aggYears;
aggActors.value = res.aggActors;
aggTags.value = res.aggTags;
aggActorTags.value = res.aggActorTags;
aggChannels.value = res.aggChannels;
total.value = res.total;

4
package-lock.json generated
View File

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

View File

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

View File

@@ -28,6 +28,10 @@ export async function onBeforeRender(pageContext) {
restriction: pageContext.restriction,
});
if (!scene) {
throw render(404, `Cannot find scene '${pageContext.routeParams.sceneId}'.`);
}
const [campaigns, tagIds] = await Promise.all([
getRandomCampaigns([
{
@@ -44,10 +48,6 @@ export async function onBeforeRender(pageContext) {
], 'tags', true),
]);
if (!scene) {
throw render(404, `Cannot find scene '${pageContext.routeParams.sceneId}'.`);
}
return {
pageContext: {
title: getTitle(scene),

View File

@@ -73,7 +73,7 @@
v-if="item.note"
v-tooltip="item.note"
icon="info2"
class="item-note"
class="item-note noselect"
/>
</div>
@@ -81,6 +81,7 @@
<Icon
v-if="!item.forced"
icon="pencil5"
class="noselect"
:class="{ active: editing.has(item.key) }"
@click="toggleField(item)"
/>
@@ -134,6 +135,15 @@
:disabled="!editing.has(item.key)"
/>
<Checkbox
v-if="item.type === 'checkbox'"
:label="item.checkboxLabel"
:checked="edits[item.key]"
:disabled="!editing.has(item.key)"
class="checkbox delete"
@change="(checked) => setDelete(checked)"
/>
<div
v-if="item.type === 'date'"
class="date"
@@ -210,9 +220,10 @@
<div class="editor-actions">
<Checkbox
v-if="user.role !== 'user'"
v-tooltip="isApplyDisabled && editing.has('delete') ? 'Delete must be approved by an admin' : null"
label="Approve and apply immediately"
:checked="apply"
:disabled="editing.size === 0"
:disabled="isApplyDisabled"
@change="(checked) => apply = checked"
/>
@@ -241,10 +252,7 @@ import EditTags from '#/components/edit/tags.vue';
import EditMovies from '#/components/edit/movies.vue';
import Checkbox from '#/components/form/checkbox.vue';
import {
// get,
post,
} from '#/src/api.js';
import { post } from '#/src/api.js';
const pageContext = inject('pageContext');
@@ -310,12 +318,20 @@ const fields = computed(() => [
},
...(user.role === 'user'
? []
: [{
key: 'comment',
type: 'text',
placeholder: 'Do NOT use this field to summarize and clarify your revision. This field is for permanent notes and comments regarding the scene or database entry itself.',
value: scene.value.comment,
}]),
: [
{
key: 'comment',
type: 'text',
placeholder: 'Do NOT use this field to summarize and clarify your revision. This field is for permanent notes and comments regarding the scene or database entry itself.',
value: scene.value.comment,
},
{
key: 'delete',
type: 'checkbox',
checkboxLabel: 'Remove this scene from the database',
value: false,
},
]),
]);
function simplifyArray(field) {
@@ -332,6 +348,9 @@ const comment = ref(null);
const apply = ref(user.role !== 'user');
const submitted = ref(false);
const userCanDelete = user.abilities.some((ability) => ability.subject === 'scene' && ability.action === 'delete');
const isApplyDisabled = computed(() => editing.value.size === 0 || (edits.value.delete && !userCanDelete));
const keyMap = {
date: {
date: 'date',
@@ -359,6 +378,14 @@ function setDuration(unit, event) {
edits.value.duration[timeUnits.indexOf(unit)] = Number(event.target.value);
}
function setDelete(checked) {
edits.value.delete = checked;
if (!userCanDelete) {
apply.value = false;
}
}
async function submit() {
try {
await post('/revisions/scenes', {
@@ -417,6 +444,10 @@ async function submit() {
display: flex;
align-items: center;
padding: .25rem 1rem;
.value.disabled {
color: var(--glass);
}
}
.key {
@@ -488,7 +519,7 @@ async function submit() {
}
}
.item-note{
.item-note {
fill: var(--glass);
padding: .5rem .75rem;
cursor: help;
@@ -518,6 +549,25 @@ async function submit() {
}
}
.checkbox.delete {
display: inline-flex;
gap: 1rem;
align-items: center;
font-weight: bold;
}
.value.disabled .delete {
:deep(.check-checkbox) + .check {
background: var(--glass-weak-30);
}
}
.value:not(.disabled) .delete {
:deep(.check-checkbox:checked) + .check {
background: var(--error);
}
}
.editor-actions {
display: flex;
flex-direction: column;
@@ -540,6 +590,27 @@ async function submit() {
line-height: 1.5;
}
.delete-title {
display: block;
margin-top: .5rem;
max-width: 25rem;
}
.dialog-body {
padding: 1rem;
}
.dialog-section {
margin-bottom: 1rem;
text-align: center;
}
.dialog-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
@media(--small) {
.row {
flex-direction: column;

BIN
public/img/rta.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -70,6 +70,9 @@ async function onRenderHtml(pageContext) {
<meta name="description" content="Keep track of new porn releases and re-discover classics from your favorite porn stars and sites" />
<!-- RTA restricted to adults label -->
<meta name="RATING" content="RTA-5042-1996-1400-1577-RTA" />
${config.analytics.enabled ? dangerouslySkipEscape(`<script src="${config.analytics.address}" data-website-id="${config.analytics.siteId}" data-exclude-hash="true" async></script>`) : ''}
<title>${title}</title>

View File

@@ -55,7 +55,7 @@ const keyMap = {
isCircumcised: 'circumcised',
};
const socialsOrder = ['onlyfans', 'twitter', 'fansly', 'loyalfans', 'manyvids', 'pornhub', 'linktree', null];
const socialsOrder = ['onlyfans', 'fansly', 'twitter', 'instagram', 'loyalfans', 'manyvids', 'pornhub', 'linktree', null];
export function curateActor(actor, context = {}) {
return {
@@ -80,6 +80,8 @@ export function curateActor(actor, context = {}) {
ageThen: context.sceneDate && actor.date_of_birth && actor.date_of_birth.getFullYear() > 1
? differenceInYears(context.sceneDate, actor.date_of_birth)
: null,
dateOfBirth: actor.date_of_birth,
dateOfDeath: actor.date_of_death,
bust: actor.bust,
cup: actor.cup,
waist: actor.waist,

View File

@@ -6,7 +6,7 @@ function getWatchUrl(scene) {
return new URL(scene.url).href;
}
if (scene.channel && (scene.channel.isIndependent || scene.channel.type === 'network')) {
if (scene.channel && (scene.channel.isIndependent || scene.channel.type === 'network' || !scene.network)) {
return new URL(scene.channel.url).href;
}

View File

@@ -17,6 +17,7 @@ import initLogger from './logger.js';
import { curateRevision } from './revisions.js';
import { getAffiliateSceneUrl } from './affiliates.js';
import { censor } from './censor.js';
import initSceneRevisions from '../common/scenes-revisions.mjs';
const logger = initLogger();
const mj = new MerkleJson();
@@ -45,6 +46,7 @@ function curateScene(rawScene, assets, reqUser, context) {
slug: assets.channel.slug,
name: censor(assets.channel.name, context.restriction),
type: assets.channel.type,
url: assets.channel.url,
isIndependent: assets.channel.independent,
hasLogo: assets.channel.has_logo,
},
@@ -52,6 +54,7 @@ function curateScene(rawScene, assets, reqUser, context) {
id: assets.channel.network_id,
slug: assets.channel.network_slug,
name: censor(assets.channel.network_name, context.restriction),
url: assets.network_url,
type: assets.channel.network_type,
hasLogo: assets.channel.network_has_logo,
} : null,
@@ -179,6 +182,7 @@ export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) {
'networks.slug as network_slug',
'networks.name as network_name',
'networks.type as network_type',
'networks.url as network_url',
'networks.has_logo as network_has_logo',
knex.raw('row_to_json(affiliates) as affiliate'),
)
@@ -402,6 +406,15 @@ function curateOptions(options) {
};
}
// function curateFacet(results, field, count = 'count(distinct id)') {
function curateFacet(results, field) {
return results
.find((result) => result.columns[0][field] && (result.columns[1]['count(distinct id)'] || result.columns[1]['count(*)']))
?.data.map((row) => ({ key: row[field], doc_count: row['count(distinct id)'] || row['count(*)'] }))
.filter((row) => !!row.key)
|| [];
}
async function queryManticoreSql(filters, options, _reqUser) {
const aggSize = config.database.manticore.maxAggregateSize;
@@ -424,6 +437,7 @@ async function queryManticoreSql(filters, options, _reqUser) {
:yearsFacet:
:actorsFacet:
:tagsFacet:
:actorTagsFacet:
:channelsFacet:
:studiosFacet:;
show meta;
@@ -457,13 +471,14 @@ async function queryManticoreSql(filters, options, _reqUser) {
weight() as _score
`));
// manticore only supports one joined table, so we can't use it inside stashes; probably not needed anyway (stashes only need global tags?)
// 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) {
// we exclude title because we have a curated title_filtered field for more effective results
builder.whereRaw('match(\'@!title :query:\', scenes)', { query: escape(filters.query) });
}
@@ -564,15 +579,14 @@ async function queryManticoreSql(filters, options, _reqUser) {
.offset((options.page - 1) * options.limit),
// 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,
actorsFacet: options.aggregateActors ? knex.raw('facet scenes.actor_ids as actors_facet distinct id order by count(distinct id) 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 // eslint-disable-line no-nested-ternary
? (filters.stashId || !filters?.actorIds || filters.actorIds.length === 0 // we can't join the tags table as well as the stashes table
? knex.raw('facet scenes.tag_ids as tags_facet order by count(distinct id) desc limit ?', [aggSize])
: knex.raw(`facet IF(IN(scenes_tags.actor_id, ${[0, ...filters?.actorIds || []]}), scenes_tags.tag_id, 0) tags_facet distinct id order by count(distinct id) desc limit ?`, [aggSize]))
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(distinct id) desc limit ?', [aggSize]) : null,
studiosFacet: options.aggregateChannels ? knex.raw('facet scenes.studio_id as studios_facet distinct id order by count(distinct id) 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,
maxMatches: config.database.manticore.maxMatches,
maxQueryTime: config.database.manticore.maxQueryTime,
}).toString();
@@ -590,32 +604,12 @@ async function queryManticoreSql(filters, options, _reqUser) {
const results = await utilsApi.sql(curatedSqlQuery);
// console.log(util.inspect(results, null, Infinity));
const years = results
.find((result) => result.columns[0].years_facet && result.columns[1]['count(*)'])
?.data.map((row) => ({ key: row.years_facet, doc_count: row['count(*)'] }))
|| [];
const actorIds = results
.find((result) => result.columns[0].actors_facet && result.columns[1]['count(distinct id)'])
?.data.map((row) => ({ key: row.actors_facet, doc_count: row['count(distinct id)'] }))
|| [];
const tagIds = results
.find((result) => result.columns[0].tags_facet && result.columns[1]['count(distinct id)'])
?.data.map((row) => ({ key: row.tags_facet, doc_count: row['count(distinct id)'] || row['count(*)'] }))
|| [];
const channelIds = results
.find((result) => result.columns[0].channels_facet && result.columns[1]['count(distinct id)'])
?.data.map((row) => ({ key: row.channels_facet || row['scenes.channel_id'], doc_count: row['count(distinct id)'] }))
|| [];
const studioIds = results
.find((result) => result.columns[0].studios_facet && result.columns[1]['count(distinct id)'])
?.data.map((row) => ({ key: row.studios_facet, doc_count: row['count(distinct id)'] })).filter((row) => !!row.key)
|| [];
const years = curateFacet(results, 'years_facet');
const actorIds = curateFacet(results, 'actors_facet');
const tagIds = curateFacet(results, 'tags_facet');
const actorTagIds = curateFacet(results, 'actor_tags_facet');
const channelIds = curateFacet(results, 'channels_facet');
const studioIds = curateFacet(results, 'studios_facet');
const total = Number(results.at(-1).data.find((entry) => entry.Variable_name === 'total_found')?.Value) || 0;
@@ -626,6 +620,7 @@ async function queryManticoreSql(filters, options, _reqUser) {
years,
actorIds,
tagIds,
actorTagIds,
channelIds,
studioIds,
},
@@ -663,9 +658,10 @@ export async function fetchScenes(filters, rawOptions, reqUser, context) {
console.time('fetch aggregations');
const [aggActors, aggTags, aggChannels] = await Promise.all([
const [aggActors, aggTags, aggActorTags, aggChannels] = await Promise.all([
options.aggregateActors ? fetchActorsById(result.aggregations.actorIds.map((bucket) => bucket.key), { shallow: true, order: ['slug', 'asc'], append: actorCounts }, reqUser) : [],
options.aggregateTags ? fetchTagsById(result.aggregations.tagIds.map((bucket) => bucket.key), { order: [knex.raw('lower(name)'), 'asc'], append: tagCounts }, reqUser, context) : [],
options.aggregateTags ? fetchTagsById(result.aggregations.actorTagIds.map((bucket) => bucket.key), { order: [knex.raw('lower(name)'), 'asc'], append: tagCounts }, reqUser, context) : [],
options.aggregateChannels ? fetchEntitiesById(entityIds.map((bucket) => bucket.key), { order: ['slug', 'asc'], append: channelCounts }, reqUser, context) : [],
]);
@@ -681,6 +677,7 @@ export async function fetchScenes(filters, rawOptions, reqUser, context) {
aggYears,
aggActors,
aggTags,
aggActorTags,
aggChannels,
total: result.total,
limit: options.limit,
@@ -733,8 +730,14 @@ export async function fetchSceneRevisions(revisionId, filters = {}, reqUser) {
.limit(limit)
.offset((page - 1) * limit);
const actorIds = Array.from(new Set(revisions.flatMap((revision) => [...revision.base.actors, ...(revision.deltas.find((delta) => delta.key === 'actors')?.value || [])])));
const tagIds = Array.from(new Set(revisions.flatMap((revision) => [...revision.base.tags, ...(revision.deltas.find((delta) => delta.key === 'tags')?.value || [])])));
const actorIds = Array.from(new Set(revisions.flatMap((revision) => [
...revision.base.actors,
...(revision.deltas.find((delta) => delta.key === 'actors')?.value || []),
...revision.base.tags.map((tag) => tag.actorId),
...revision.deltas.find((delta) => delta.key === 'tags')?.value.map((tag) => tag.actorId) || [],
].filter(Boolean))));
const tagIds = Array.from(new Set(revisions.flatMap((revision) => [...revision.base.tags, ...(revision.deltas.find((delta) => delta.key === 'tags')?.value || [])].map((tag) => tag.id))));
const movieIds = Array.from(new Set(revisions.flatMap((revision) => [...revision.base.movies, ...(revision.deltas.find((delta) => delta.key === 'movies')?.value || [])])));
const [actors, tags, movies] = await Promise.all([
@@ -754,6 +757,20 @@ export async function fetchSceneRevisions(revisionId, filters = {}, reqUser) {
};
}
const { createSceneRevision, reviewSceneRevision } = initSceneRevisions({
config,
knex,
mj,
logger,
fetchScenesById,
});
export {
createSceneRevision,
reviewSceneRevision,
};
/*
const keyMap = {
datePrecision: 'date_precision',
productionDate: 'production_date',
@@ -821,7 +838,18 @@ async function applySceneMoviesDelta(sceneId, delta, trx) {
}
}
async function applySceneRevision(revisionIds) {
async function applySceneDeleteDelta(sceneId, _delta, trx, reqUser) {
if (!reqUser.abilities.some((ability) => ability.subject === 'scene' && ability.action === 'delete')) {
throw new HttpError('You are not privileged to delete scenes', 400);
}
await knexOwner('releases')
.where('id', sceneId)
.delete()
.transacting(trx);
}
async function applySceneRevision(revisionIds, reqUser) {
const revisions = await knexOwner('scenes_revisions')
.whereIn('id', revisionIds)
.whereNull('applied_at'); // should not re-apply revision that was already applied
@@ -831,6 +859,10 @@ async function applySceneRevision(revisionIds) {
await knexOwner.transaction(async (trx) => {
await Promise.all(revision.deltas.map(async (delta) => {
if (delta.key === 'delete') {
return applySceneDeleteDelta(revision.scene_id, delta, trx, reqUser);
}
if ([
'title',
'description',
@@ -862,11 +894,13 @@ async function applySceneRevision(revisionIds) {
await knexOwner('scenes_revisions')
.where('id', revision.id)
.update('applied_at', knex.fn.now());
.update('applied_at', knexOwner.fn.now())
.transacting(trx);
// await trx.commit();
}).catch(async (error) => {
logger.error(`Failed to apply revision ${revision.id} on scene ${revision.scene_id}: ${error.message}`);
throw error;
});
}, Promise.resolve());
}
@@ -896,7 +930,19 @@ export async function reviewSceneRevision(revisionId, isApproved, { feedback },
}
if (isApproved) {
await applySceneRevision([revisionId]);
try {
await applySceneRevision([revisionId], reqUser);
} catch (error) {
await knexOwner('scenes_revisions')
.where('id', revisionId)
.update({
approved: null,
reviewed_at: null,
reviewed_by: null,
});
throw error;
}
}
}
@@ -942,6 +988,13 @@ export async function createSceneRevision(sceneId, { edits, comment, apply }, re
return [key, values.id];
}
if (key === 'tags') {
return [key, values.map((tag) => ({
id: tag.id,
actorId: tag.actorId,
}))];
}
if (Array.isArray(values)) {
return [key, values.map((value) => value?.hash || value?.id || value)];
}
@@ -950,10 +1003,24 @@ export async function createSceneRevision(sceneId, { edits, comment, apply }, re
}).filter(Boolean));
const deltas = Object.entries(edits).map(([key, value]) => {
if (key === 'delete') {
return { key: 'delete' };
}
if (baseScene[key] === value || typeof value === 'undefined') {
return null;
}
if (key === 'tags') {
return {
key,
value: value.map((tag) => ({
id: tag.id,
actorId: tag.actorId,
})),
};
}
if (Array.isArray(value)) {
const valueSet = new Set(value);
const baseSet = new Set(baseScene[key]);
@@ -988,6 +1055,7 @@ export async function createSceneRevision(sceneId, { edits, comment, apply }, re
if (['admin', 'editor'].includes(reqUser.role) && apply) {
// don't keep the editor waiting for the revision to apply
reviewSceneRevision(revisionEntry.id, true, {}, reqUser);
reviewSceneRevision(revisionEntry.id, true, {}, reqUser).catch(() => {});
}
}
*/

View File

@@ -1,82 +0,0 @@
import { indexApi, utilsApi } from '../manticore.js';
import rawvideos from './movies.json' with { type: 'json' };
async function fetchvideos() {
const videos = rawvideos
.filter((video) => video.cast.length > 0
&& video.genres.length > 0
&& video.cast.every((actor) => actor.charCodeAt(0) >= 65)) // throw out videos with non-alphanumerical actor names
.map((video, index) => ({ id: index + 1, ...video }));
const actors = Array.from(new Set(videos.flatMap((video) => video.cast))).sort();
const genres = Array.from(new Set(videos.flatMap((video) => video.genres)));
return {
videos,
actors,
genres,
};
}
async function init() {
await utilsApi.sql('drop table if exists videos');
await utilsApi.sql('drop table if exists videos_liked');
await utilsApi.sql(`create table videos (
id int,
title text,
actor_ids multi,
actors text,
genre_ids multi,
genres text
)`);
await utilsApi.sql(`create table videos_liked (
id int,
user_id int,
video_id int
)`);
const { videos, actors, genres } = await fetchvideos();
const likedvideoIds = Array.from(new Set(Array.from({ length: 10.000 }, () => videos[Math.round(Math.random() * videos.length)].id)));
const docs = videos
.map((video) => ({
replace: {
index: 'videos',
id: video.id,
doc: {
title: video.title,
actor_ids: video.cast.map((actor) => actors.indexOf(actor)),
actors: video.cast.join(','),
genre_ids: video.genres.map((genre) => genres.indexOf(genre)),
genres: video.genres.join(','),
},
},
}))
.concat(likedvideoIds.map((videoId, index) => ({
replace: {
index: 'videos_liked',
id: index + 1,
doc: {
user_id: Math.floor(Math.random() * 51),
video_id: videoId,
},
},
})));
const data = await indexApi.bulk(docs.map((doc) => JSON.stringify(doc)).join('\n'));
console.log('data', data);
const result = await utilsApi.sql(`
select * from videos_liked
limit 10
`);
console.log(result[0].data);
console.log(result[1]);
}
init();

View File

@@ -1,174 +0,0 @@
// import config from 'config';
import { format } from 'date-fns';
import { faker } from '@faker-js/faker';
import { indexApi } from '../manticore.js';
import { knexOwner as knex } from '../knex.js';
import slugify from '../utils/slugify.js';
import chunk from '../utils/chunk.js';
async function fetchScenes() {
const scenes = await knex.raw(`
SELECT
releases.id AS id,
releases.title,
releases.created_at,
releases.date,
releases.shoot_id,
scenes_meta.stashed,
entities.id as channel_id,
entities.slug as channel_slug,
entities.name as channel_name,
parents.id as network_id,
parents.slug as network_slug,
parents.name as network_name,
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
FROM releases
LEFT JOIN scenes_meta ON scenes_meta.scene_id = releases.id
LEFT JOIN entities ON releases.entity_id = entities.id
LEFT JOIN entities AS parents ON parents.id = entities.parent_id
LEFT JOIN releases_actors AS local_actors ON local_actors.release_id = releases.id
LEFT JOIN releases_directors AS local_directors ON local_directors.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 AS directors ON local_directors.director_id = directors.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
GROUP BY
releases.id,
releases.title,
releases.created_at,
releases.date,
releases.shoot_id,
scenes_meta.stashed,
entities.id,
entities.name,
entities.slug,
entities.alias,
parents.id,
parents.name,
parents.slug,
parents.alias;
`);
const actors = Object.fromEntries(scenes.rows.flatMap((row) => row.actors.map((actor) => [actor.f1, faker.person.fullName()])));
const tags = Object.fromEntries(scenes.rows.flatMap((row) => row.tags.map((tag) => [tag.f1, faker.word.adjective()])));
return scenes.rows.map((row) => {
const title = faker.lorem.lines(1);
const channelName = faker.company.name();
const channelSlug = slugify(channelName, '');
const networkName = faker.company.name();
const networkSlug = slugify(networkName, '');
const rowActors = row.actors.map((actor) => ({ f1: actor.f1, f2: actors[actor.f1] }));
const rowTags = row.tags.map((tag) => ({ f1: tag.f1, f2: tags[tag.f1], f3: tag.f3 }));
return {
...row,
title,
actors: rowActors,
tags: rowTags,
channel_name: channelName,
channel_slug: channelSlug,
network_name: networkName,
network_slug: networkSlug,
};
});
}
async function updateStashed(docs) {
await chunk(docs, 1000).reduce(async (chain, docsChunk) => {
await chain;
const sceneIds = docsChunk.map((doc) => doc.replace.id);
const stashes = await knex('stashes_scenes')
.select('stashes_scenes.id as stashed_id', 'stashes_scenes.scene_id', 'stashes.id as stash_id', 'stashes.user_id as user_id')
.leftJoin('stashes', 'stashes.id', 'stashes_scenes.stash_id')
.whereIn('scene_id', sceneIds);
if (stashes.length > 0) {
console.log(stashes);
}
const stashDocs = docsChunk.flatMap((doc) => {
const sceneStashes = stashes.filter((stash) => stash.scene_id === doc.replace.id);
if (sceneStashes.length === 0) {
return [];
}
const stashDoc = sceneStashes.map((stash) => ({
replace: {
index: 'scenes_stashed',
id: stash.stashed_id,
doc: {
// ...doc.replace.doc,
scene_id: doc.replace.id,
user_id: stash.user_id,
},
},
}));
return stashDoc;
});
console.log(stashDocs);
if (stashDocs.length > 0) {
await indexApi.bulk(stashDocs.map((doc) => JSON.stringify(doc)).join('\n'));
}
}, Promise.resolve());
}
async function init() {
const scenes = await fetchScenes();
const docs = scenes.map((scene) => {
const flatActors = scene.actors.flatMap((actor) => actor.f2.match(/[\w']+/g)); // match word characters to filter out brackets etc.
const flatTags = scene.tags.filter((tag) => tag.f3 > 6).flatMap((tag) => (tag.f4 ? `${tag.f2} ${tag.f4}` : tag.f2).match(/[\w']+/g)); // only make top tags searchable to minimize cluttered results
const filteredTitle = scene.title && [...flatActors, ...flatTags].reduce((accTitle, tag) => accTitle.replace(new RegExp(tag.replace(/[^\w\s]+/g, ''), 'i'), ''), scene.title).trim().replace(/\s{2,}/, ' ');
return {
replace: {
index: 'scenes',
id: scene.id,
doc: {
title: scene.title || undefined,
title_filtered: filteredTitle || undefined,
date: scene.date ? Math.round(scene.date.getTime() / 1000) : undefined,
created_at: Math.round(scene.created_at.getTime() / 1000),
effective_date: Math.round((scene.date || scene.created_at).getTime() / 1000),
// shoot_id: scene.shoot_id || undefined,
channel_id: scene.channel_id,
channel_slug: scene.channel_slug,
channel_name: scene.channel_name,
network_id: scene.network_id || undefined,
network_slug: scene.network_slug || undefined,
network_name: scene.network_name || undefined,
actor_ids: scene.actors.map((actor) => actor.f1),
actors: scene.actors.map((actor) => actor.f2).join(),
tag_ids: scene.tags.map((tag) => tag.f1),
tags: flatTags.join(' '),
meta: scene.date ? format(scene.date, 'y yy M MMM MMMM d') : undefined,
liked: scene.stashed || 0,
},
},
};
});
const data = await indexApi.bulk(docs.map((doc) => JSON.stringify(doc)).join('\n'));
await updateStashed(docs);
console.log('data', data);
knex.destroy();
}
init();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,80 @@
import { MerkleJson } from 'merkle-json';
import knex from '../knex.js';
const mj = new MerkleJson();
function curateTag(tag) {
if (Object.hasOwn(tag, 'actorId')) {
return {
id: tag.id,
actorId: tag.actorId,
};
}
if (typeof tag === 'number') {
return {
id: tag,
// can't restore actorId, don't set to null to hint at missing data
};
}
throw new Error(`Unrecognized tag delta: ${JSON.stringify(tag)}`);
}
async function init() {
const revisions = await knex('scenes_revisions');
// console.log(revisions);
const fixedRevisions = revisions.map((revision) => {
if (revision.base.tags.length === 0 && !revision.deltas.some((delta) => delta.key === 'tags')) {
return null;
}
const newDeltas = revision.deltas.map((delta) => {
if (delta.key !== 'tags') {
return delta;
}
return {
...delta,
value: delta.value.map((tag) => curateTag(tag)),
};
});
const newBase = {
...revision.base,
tags: revision.base.tags.map((tag) => curateTag(tag)),
};
return {
...revision,
deltas: newDeltas,
base: newBase,
};
}).filter(Boolean);
const entries = fixedRevisions.map((revision) => ({
id: revision.id,
base: JSON.stringify(revision.base),
deltas: JSON.stringify(revision.deltas),
hash: mj.hash({
base: revision.base,
deltas: revision.deltas,
}),
}));
console.log(entries);
await knex('scenes_revisions')
.insert(entries)
.onConflict('id')
.merge(['base', 'deltas', 'hash']);
console.log(`Fixed ${entries.length} revisions`);
await knex.destroy();
}
init();

View File

@@ -1,50 +0,0 @@
import { indexApi, utilsApi } from '../manticore.js';
import { knexOwner as knex } from '../knex.js';
import chunk from '../utils/chunk.js';
async function syncStashes(domain = 'scene') {
await utilsApi.sql(`truncate table ${domain}s_stashed`);
const stashes = await knex(`stashes_${domain}s`)
.select(
`stashes_${domain}s.id as stashed_id`,
`stashes_${domain}s.${domain}_id`,
'stashes.id as stash_id',
'stashes.user_id as user_id',
`stashes_${domain}s.created_at as created_at`,
)
.leftJoin('stashes', 'stashes.id', `stashes_${domain}s.stash_id`);
await chunk(stashes, 1000).reduce(async (chain, stashChunk, index) => {
await chain;
const stashDocs = stashChunk.map((stash) => ({
replace: {
index: `${domain}s_stashed`,
id: stash.stashed_id,
doc: {
[`${domain}_id`]: stash[`${domain}_id`],
stash_id: stash.stash_id,
user_id: stash.user_id,
created_at: Math.round(stash.created_at.getTime() / 1000),
},
},
}));
await indexApi.bulk(stashDocs.map((doc) => JSON.stringify(doc)).join('\n'));
console.log(`Synced ${index * 1000 + stashChunk.length}/${stashes.length} ${domain} stashes`);
}, Promise.resolve());
}
async function init() {
await syncStashes('scene');
await syncStashes('actor');
await syncStashes('movie');
console.log('Done!');
knex.destroy();
}
init();

View File

@@ -2,14 +2,14 @@ export default function consentHandler(req, res, next) {
const redirect = req.headers.referer && new URL(req.headers.referer).searchParams.get('redirect');
if (Object.hasOwn(req.query, 'lgbt')) {
const lgbtFilters = (req.tagFilter || []).filter((tag) => !['gay', 'bisexual', 'transsexual'].includes(tag));
const lgbtFilters = Array.from(new Set([...(req.tagFilter || []).filter((tag) => !['gay', 'bisexual', 'transsexual'].includes(tag)), 'extreme-insertion']));
req.tagFilter = lgbtFilters; // eslint-disable-line no-param-reassign
res.cookie('tags', JSON.stringify(lgbtFilters));
}
if (Object.hasOwn(req.query, 'straight')) {
const straightFilters = Array.from(new Set([...(req.tagFilter || []), 'gay', 'bisexual', 'transsexual']));
const straightFilters = Array.from(new Set([...(req.tagFilter || []), 'gay', 'bisexual', 'transsexual', 'extreme-insertion']));
req.tagFilter = straightFilters; // eslint-disable-line no-param-reassign
res.cookie('tags', JSON.stringify(straightFilters));

View File

@@ -59,6 +59,7 @@ async function fetchScenesApi(req, res) {
aggYears,
aggActors,
aggTags,
aggActorTags,
aggChannels,
limit,
total,
@@ -77,6 +78,7 @@ async function fetchScenesApi(req, res) {
aggYears,
aggActors,
aggTags,
aggActorTags,
aggChannels,
limit,
total,

2
static

Submodule static updated: 217845ef37...4c6f9888dc