Compare commits

...

39 Commits

Author SHA1 Message Date
1f58f989f7 0.50.0 2026-06-08 05:50:55 +02:00
96b1a99e04 Added manticore sync API. 2026-06-08 05:50:29 +02:00
1bc7dd3a43 Integrated Manticore sync, assuming responsibility from traxxx core/legacy. 2026-06-08 05:18:11 +02:00
a048970be6 Merge branch 'main' into merge-actors 2026-05-20 17:53:54 +02:00
e3171e5693 0.49.8 2026-05-20 17:53:40 +02:00
d463b3df5c Added guards for stash operations with missing arguments. 2026-05-20 17:53:38 +02:00
1ae7befa4b Added actor merge dialog UI. 2026-05-20 06:21:56 +02:00
dc80e1e199 Added actor creation page. 2026-05-20 05:27:37 +02:00
35ffc2b0f7 0.49.7 2026-05-06 17:57:36 +02:00
383844dda8 Not showing parent campaigns on independent sites. 2026-05-06 17:57:33 +02:00
77fb6595a2 Added inauthenticated user handling to createSceneRevision. 2026-04-01 00:05:30 +02:00
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
45 changed files with 1685 additions and 648527 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" /> <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> <title>traxxx - Consent</title>
<style> <style>
:root { :root {
@@ -156,6 +159,12 @@
text-decoration: underline; text-decoration: underline;
} }
.rta {
position: fixed;
bottom: .5rem;
right: .5rem;
}
@media(max-width: 800px) { @media(max-width: 800px) {
.heading { .heading {
font-size: 1.25rem; font-size: 1.25rem;
@@ -219,5 +228,12 @@
</a> </a>
</div> </div>
</div> </div>
<img
src="/img/rta.gif"
alt="RTA Restricted To Adults"
title="RTA Restricted To Adults"
class="rta"
>
</body> </body>
</html> </html>

View File

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

2
common

Submodule common updated: ec4b15ce33...e4d6ff6ad1

View File

@@ -80,10 +80,17 @@
<span <span
v-if="actor.origin.city" v-if="actor.origin.city"
class="city" class="city"
>{{ actor.origin.city }}</span><span >{{ actor.origin.city }}</span>
<span
v-if="actor.origin.state && (!actor.origin.city || (actor.origin.country && actor.origin.country.alpha2 === 'US'))" v-if="actor.origin.state && (!actor.origin.city || (actor.origin.country && actor.origin.country.alpha2 === 'US'))"
class="state" class="state"
>{{ actor.origin.city ? `, ${actor.origin.state}` : actor.origin.state }}</span> >
{{ actor.origin.city
? [',', actor.origin.state].join(' ')
: actor.origin.state
}}
</span>
<span <span
v-if="actor.origin.country" v-if="actor.origin.country"
@@ -105,7 +112,7 @@
class="bio-item residence" class="bio-item residence"
:class="{ hideable: !!actor.origin }" :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>
<span <span
@@ -312,10 +319,10 @@
<a <a
v-for="social in socials" v-for="social in socials"
:key="`social-${social.id}`" :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)" :href="getSocialUrl(social)"
target="_blank" target="_blank"
rel="noopener" rel="noopener"
:title="social.platform || social.url"
class="social ellipsis" class="social ellipsis"
> >
<Icon <Icon
@@ -339,7 +346,9 @@
:class="`icon-social icon-${social.platform} icon-generic`" :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 }} <template v-if="social.platform">{{ env.socials.prefix[social.platform] || env.socials.prefix.default }}</template>{{ social.handle }}
-->
</a> </a>
</ul> </ul>
</div> </div>
@@ -363,10 +372,22 @@
target="_blank" target="_blank"
class="link" class="link"
>Revisions</a> >Revisions</a>
<span
v-if="user && user.role !== 'user'"
class="link"
@click="showMergeDialog = true"
>Merge</span>
</div> </div>
</li> </li>
</ul> </ul>
<Merge
v-if="showMergeDialog"
:actor="actor"
@close="showMergeDialog = false"
/>
<div class="descriptions-container"> <div class="descriptions-container">
<div <div
v-if="descriptions.length > 0" v-if="descriptions.length > 0"
@@ -427,6 +448,8 @@ import formatTemplate from 'template-format';
import getPath from '#/src/get-path.js'; import getPath from '#/src/get-path.js';
import { formatDate } from '#/utils/format.js'; import { formatDate } from '#/utils/format.js';
import Merge from '#/components/actors/merge.vue';
const expanded = ref(false); const expanded = ref(false);
const pageContext = inject('pageContext'); const pageContext = inject('pageContext');
@@ -498,12 +521,14 @@ function getSocialUrl(social) {
return null; return null;
} }
const socials = props.actor.socials.map((social) => ({ const socials = props.actor.socials.slice(0, 10).map((social) => ({
...social, ...social,
handle: social.url handle: social.url
? new URL(social.url).hostname ? new URL(social.url).hostname
: social.handle, : social.handle,
})); }));
const showMergeDialog = ref(false);
</script> </script>
<style> <style>
@@ -735,19 +760,23 @@ const socials = props.actor.socials.map((social) => ({
} }
.socials { .socials {
display: flex;
flex-wrap: wrap;
/*
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
grid-gap: 0 0; grid-gap: 0 0;
overflow: hidden; overflow: hidden;
*/
gap: .25rem; gap: .25rem;
padding: 0; padding: 0;
} }
.social { .social {
display: flex; display: inline-flex;
height: 2rem;
align-items: center; align-items: center;
padding: .1rem .5rem; justify-content: center;
padding: .75rem .75rem;
border-radius: .25rem; border-radius: .25rem;
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
@@ -755,10 +784,6 @@ const socials = props.actor.socials.map((social) => ({
font-weight: normal; font-weight: normal;
background: var(--highlight-weak-40); background: var(--highlight-weak-40);
.icon {
margin-right: .5rem;
}
.icon-generic { .icon-generic {
fill: var(--highlight); fill: var(--highlight);
} }
@@ -783,6 +808,7 @@ const socials = props.actor.socials.map((social) => ({
.link { .link {
color: inherit; color: inherit;
flex-shrink: 0; flex-shrink: 0;
cursor: pointer;
} }
} }

186
components/actors/merge.vue Normal file
View File

@@ -0,0 +1,186 @@
<template>
<Dialog
:title="`Merge '${actor.name}'`"
@close="emit('close')"
>
<div class="dialog-body">
<strong class="source">#{{ actor.id }} {{ actor.name }}<span v-if="actor.entity"> ({{ actor.entity.name }})</span></strong>
<span class="path">merging into</span>
<div
v-if="targetActor"
class="target"
>
<strong class="target-name">
<span class="target-id">#{{ targetActor.id }}</span>
{{ targetActor.name }}
</strong>
<Icon
icon="cross2"
@click="targetActor = null"
/>
</div>
<template v-else>
<VDropdown
:triggers="[]"
:shown="actorResults.length > 0"
:auto-hide="false"
>
<input
ref="actorInput"
v-model="actorQuery"
class="input"
placeholder="Search target actor"
@input="searchActors"
>
<template #popper>
<ul
class="results nolist"
>
<li
v-for="actorResult in actorResults"
:key="`actor-result-${actorResult.id}`"
v-close-popper
class="result-item"
@click="selectActor(actorResult)"
>
<div class="result-label">
<span class="result-id">#{{ actorResult.id }}</span> {{ actorResult.name }}
</div>
</li>
</ul>
</template>
</VDropdown>
</template>
<div class="dialog-actions">
<button
type="submit"
class="button button-primary"
:disabled="!targetActor"
@click="merge"
>Merge</button>
</div>
</div>
</Dialog>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import Dialog from '#/components/dialog/dialog.vue';
import { get, post } from '#/src/api.js';
const props = defineProps({
actor: {
type: Object,
default: null,
},
});
const emit = defineEmits(['close']);
const targetActor = ref(null);
const actorInput = ref(null);
const actorQuery = ref('');
const actorResults = ref([]);
async function searchActors() {
const res = await get('/actors', {
q: `${actorQuery.value}*`, // return partial matches
limit: 10,
global: true,
});
actorResults.value = res.actors;
}
async function merge() {
await post(`/actors/${targetActor.value.id}/merge/${props.actor.id}`, null, {
successFeedback: `Merged ${props.actor.entity ? `${props.actor.name} (${props.actor.entity.name})` : props.actor.name} into ${targetActor.value.name}`,
errorFeedback: `Failed to merge ${props.actor.entity ? `${props.actor.name} (${props.actor.entity.name})` : props.actor.name} into ${targetActor.value.name}`,
appendErrorMessage: true,
});
emit('close');
}
function selectActor(actor) {
targetActor.value = actor;
actorQuery.value = '';
actorResults.value = [];
}
onMounted(() => {
actorInput.value.focus();
});
</script>
<style scoped>
.dialog-body {
width: 20rem;
max-width: 100%;
box-sizing: border-box;
padding: 1rem;
gap: 1rem;
}
.dialog-actions {
.button {
width: 100%;
}
}
.input {
width: 100%;
}
.path {
color: var(--glass-strong-20);
}
.target {
display: flex;
justify-content: space-between;
align-items: center;
.icon {
padding: .25rem .75rem;
fill: var(--glass);
&:hover {
fill: var(--error);
cursor: pointer;
}
}
}
.target-id {
font-family: monospace;
font-size: 1rem;
}
.results {
padding: .25rem 0;
}
.result-item {
display: flex;
padding: .25rem .5rem;
&:hover {
cursor: pointer;
color: var(--primary);
}
}
.result-id {
font-family: monospace;
font-size: 1rem;
}
</style>

View File

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

View File

@@ -266,7 +266,7 @@ const expanded = ref(new Set());
const mappedKeys = { const mappedKeys = {
actors: actorsById, actors: actorsById,
tags: tagsById, // tags: tagsById,
movies: moviesById, 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') { if (key === 'socials') {
// new socials don't have IDs yet, so we need to compare the values // new socials don't have IDs yet, so we need to compare the values
return [key, value.map((item) => ({ 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') { if (delta.key === 'socials') {
// new socials don't have IDs yet, so we need to compare the values // new socials don't have IDs yet, so we need to compare the values
return { return {
@@ -378,6 +401,8 @@ async function reviewRevision(revision, isApproved) {
await post(`/revisions/${domain}/${revision.id}/reviews`, { await post(`/revisions/${domain}/${revision.id}/reviews`, {
isApproved, isApproved,
feedback: feedbacks.value[revision.id], feedback: feedbacks.value[revision.id],
}, {
appendErrorMessage: true,
}); });
const updatedRevision = await get(`/revisions/${domain}/${revision.id}`, { const updatedRevision = await get(`/revisions/${domain}/${revision.id}`, {

View File

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

4
package-lock.json generated
View File

@@ -1,11 +1,11 @@
{ {
"name": "traxxx-web", "name": "traxxx-web",
"version": "0.47.11", "version": "0.50.0",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"version": "0.47.11", "version": "0.50.0",
"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.47.11", "version": "0.50.0",
"imports": { "imports": {
"#/*": "./*.js" "#/*": "./*.js"
} }

View File

@@ -1 +0,0 @@
export default '/actor/edit/@actorId/*';

View File

@@ -7,12 +7,12 @@
<template v-if="apply">Your revision has been submitted. Thank you for your contribution!</template> <template v-if="apply">Your revision has been submitted. Thank you for your contribution!</template>
<template v-else>Your revision has been submitted for review. Thank you for your contribution!</template> <template v-else>Your revision has been submitted for review. Thank you for your contribution!</template>
<ul> <ul v-if="actor">
<li> <li>
<a <a
:href="`/actor/${actor.id}/${actor.slug}`" :href="`/actor/${actor.id}/${actor.slug}`"
class="link" class="link"
>Return to actor</a> >{{ creating ? 'Go' : 'Return' }} to actor</a>
</li> </li>
<li> <li>
@@ -22,21 +22,21 @@
>Make another edit</a> >Make another edit</a>
</li> </li>
<li> <li v-if="!creating">
<a <a
:href="`/actor/revs/${actor.id}/${actor.slug}`" :href="`/actor/revs/${actor.id}/${actor.slug}`"
class="link" class="link"
>Go to actor revisions</a> >Go to actor revisions</a>
</li> </li>
<li> <li v-if="!creating">
<a <a
:href="`/user/${user.username}/revisions/actors`" :href="`/user/${user.username}/revisions/actors`"
class="link" class="link"
>Go to user revisions</a> >Go to user revisions</a>
</li> </li>
<li v-if="user.role !== 'user'"> <li v-if="user.role !== 'user' && !creating">
<a <a
href="/admin/revisions/actors" href="/admin/revisions/actors"
class="link" class="link"
@@ -49,7 +49,10 @@
v-else v-else
@submit.prevent @submit.prevent
> >
<div class="editor-header"> <div
v-if="actor"
class="editor-header"
>
<h2 class="heading ellipsis">Edit actor #{{ actor.id }} - {{ actor.name }}</h2> <h2 class="heading ellipsis">Edit actor #{{ actor.id }} - {{ actor.name }}</h2>
<a <a
@@ -59,6 +62,13 @@
>Go to actor</a> >Go to actor</a>
</div> </div>
<div
v-else
class="editor-header"
>
<h2 class="heading ellipsis">Add actor</h2>
</div>
<ul class="nolist"> <ul class="nolist">
<li <li
v-for="item in fields" v-for="item in fields"
@@ -281,6 +291,7 @@
</select> </select>
<input <input
v-if="item.hasDescription !== false"
v-model="edits[item.key].description" v-model="edits[item.key].description"
class="description input" class="description input"
placeholder="Description" placeholder="Description"
@@ -317,7 +328,7 @@
<div class="editor-actions"> <div class="editor-actions">
<Checkbox <Checkbox
v-if="user.role !== 'user'" v-if="user.role !== 'user' && actor"
label="Approve and apply immediately" label="Approve and apply immediately"
:checked="apply" :checked="apply"
:disabled="editing.size === 0" :disabled="editing.size === 0"
@@ -346,6 +357,7 @@
<script setup> <script setup>
import { ref, computed, inject } from 'vue'; import { ref, computed, inject } from 'vue';
import { format } from 'date-fns'; import { format } from 'date-fns';
import omit from 'object.omit';
import EditSocials from '#/components/edit/socials.vue'; import EditSocials from '#/components/edit/socials.vue';
import EditPlace from '#/components/edit/place.vue'; import EditPlace from '#/components/edit/place.vue';
@@ -361,13 +373,15 @@ import {
post, post,
} from '#/src/api.js'; } from '#/src/api.js';
import events from '#/src/events.js';
const pageContext = inject('pageContext'); const pageContext = inject('pageContext');
const user = pageContext.user; const user = pageContext.user;
const actor = ref(pageContext.pageProps.actor); const actor = ref(pageContext.pageProps.actor || null);
const fields = computed(() => [ const fields = computed(() => [
...(actor.value.photos.length > 0 ? [{ ...(actor.value?.photos.length > 0 ? [{
key: 'avatar', key: 'avatar',
type: 'avatar', type: 'avatar',
value: actor.value.avatar?.id, value: actor.value.avatar?.id,
@@ -377,13 +391,13 @@ const fields = computed(() => [
? [{ ? [{
key: 'name', key: 'name',
type: 'string', type: 'string',
value: actor.value.name, value: actor.value?.name,
}] }]
: []), : []),
{ {
key: 'gender', key: 'gender',
type: 'select', type: 'select',
value: actor.value.gender, value: actor.value?.gender || null,
options: [null, 'female', 'male', 'transsexual', 'other'], options: [null, 'female', 'male', 'transsexual', 'other'],
inline: true, inline: true,
}, },
@@ -391,7 +405,7 @@ const fields = computed(() => [
key: 'dateOfBirth', key: 'dateOfBirth',
label: 'date of birth', label: 'date of birth',
type: 'date', type: 'date',
value: actor.value.dateOfBirth value: actor.value?.dateOfBirth
? format(actor.value.dateOfBirth, 'yyyy-MM-dd') ? format(actor.value.dateOfBirth, 'yyyy-MM-dd')
: null, : null,
inline: true, inline: true,
@@ -399,28 +413,28 @@ const fields = computed(() => [
{ {
key: 'socials', key: 'socials',
type: 'socials', type: 'socials',
value: actor.value.socials, value: actor.value?.socials || [],
}, },
{ {
key: 'origin', key: 'origin',
type: 'place', type: 'place',
value: { value: {
country: actor.value.origin?.country?.alpha2 || null, country: actor.value?.origin?.country?.alpha2 || null,
place: [actor.value.origin?.city, actor.value.origin?.state].filter(Boolean).join(', '), place: [actor.value?.origin?.city, actor.value?.origin?.state].filter(Boolean).join(', '),
}, },
}, },
{ {
key: 'residence', key: 'residence',
type: 'place', type: 'place',
value: { value: {
country: actor.value.residence?.country?.alpha2 || null, country: actor.value?.residence?.country?.alpha2 || null,
place: [actor.value.residence?.city, actor.value.residence?.state].filter(Boolean).join(', '), place: [actor.value?.residence?.city, actor.value?.residence?.state].filter(Boolean).join(', '),
}, },
}, },
{ {
key: 'ethnicity', key: 'ethnicity',
type: 'string', type: 'string',
value: actor.value.ethnicity, value: actor.value?.ethnicity || null,
suggestions: [ suggestions: [
'Asian', 'Asian',
'Black', 'Black',
@@ -433,20 +447,20 @@ const fields = computed(() => [
key: 'size', key: 'size',
type: 'size', type: 'size',
value: { value: {
metricHeight: actor.value.height?.metric, metricHeight: actor.value?.height?.metric || null,
metricWeight: actor.value.weight?.metric, metricWeight: actor.value?.weight?.metric || null,
imperialHeight: actor.value.height?.imperial || [], imperialHeight: actor.value?.height?.imperial || [],
imperialWeight: actor.value.weight?.imperial, imperialWeight: actor.value?.weight?.imperial || null,
}, },
}, },
{ {
key: 'figure', key: 'figure',
type: 'figure', type: 'figure',
value: { value: {
bust: actor.value.bust, bust: actor.value?.bust || null,
cup: actor.value.cup, cup: actor.value?.cup || null,
waist: actor.value.waist, waist: actor.value?.waist || null,
hip: actor.value.hip, hip: actor.value?.hip || null,
}, },
}, },
{ {
@@ -454,25 +468,25 @@ 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, naturalBoobs: actor.value?.naturalBoobs || null,
boobsVolume: actor.value.boobsVolume, boobsVolume: actor.value?.boobsVolume || null,
boobsImplant: actor.value.boobsImplant, boobsImplant: actor.value?.boobsImplant || null,
boobsPlacement: actor.value.boobsPlacement, boobsPlacement: actor.value?.boobsPlacement || null,
boobsIncision: actor.value.boobsIncision, boobsIncision: actor.value?.boobsIncision || null,
boobsSurgeon: actor.value.boobsSurgeon, boobsSurgeon: actor.value?.boobsSurgeon || null,
naturalButt: actor.value.naturalButt, naturalButt: actor.value?.naturalButt || null,
buttVolume: actor.value.buttVolume, buttVolume: actor.value?.buttVolume || null,
buttImplant: actor.value.buttImplant, buttImplant: actor.value?.buttImplant || null,
naturalLips: actor.value.naturalLips, naturalLips: actor.value?.naturalLips || null,
lipsVolume: actor.value.lipsVolume, lipsVolume: actor.value?.lipsVolume || null,
naturalLabia: actor.value.naturalLabia, naturalLabia: actor.value?.naturalLabia || null,
}, },
}, },
{ {
key: 'hairColor', key: 'hairColor',
label: 'hair color', label: 'hair color',
type: 'select', type: 'select',
value: actor.value.hairColor, value: actor.value?.hairColor || null,
options: [ options: [
null, null,
'black', 'black',
@@ -491,7 +505,7 @@ const fields = computed(() => [
key: 'eyes', key: 'eyes',
label: 'eye color', label: 'eye color',
type: 'select', type: 'select',
value: actor.value.eyes, value: actor.value?.eyes || null,
options: [ options: [
null, null,
'blue', 'blue',
@@ -506,8 +520,8 @@ const fields = computed(() => [
key: 'tattoos', key: 'tattoos',
type: 'has', type: 'has',
value: { value: {
has: actor.value.hasTattoos, has: actor.value?.hasTattoos || null,
description: actor.value.tattoos, description: actor.value?.tattoos || null,
}, },
}, },
{ {
@@ -515,14 +529,14 @@ const fields = computed(() => [
type: 'has', type: 'has',
note: 'Excludes earrings', note: 'Excludes earrings',
value: { value: {
has: actor.value.hasPiercings, has: actor.value?.hasPiercings || null,
description: actor.value.piercings, description: actor.value?.piercings || null,
}, },
}, },
{ {
key: 'agency', key: 'agency',
type: 'string', type: 'string',
value: actor.value.agency, value: actor.value?.agency || null,
suggestions: [ suggestions: [
'101 Modeling', '101 Modeling',
'Adult Talent Managers (ATMLA)', 'Adult Talent Managers (ATMLA)',
@@ -540,25 +554,41 @@ const fields = computed(() => [
key: 'penis', key: 'penis',
type: 'penis', type: 'penis',
value: { value: {
metricLength: actor.value.penisLength?.metric, metricLength: actor.value?.penisLength?.metric || null,
metricGirth: actor.value.penisGirth?.metric, metricGirth: actor.value?.penisGirth?.metric || null,
imperialLength: actor.value.penisLength?.imperial, imperialLength: actor.value?.penisLength?.imperial || null,
imperialGirth: actor.value.penisGirth?.imperial, imperialGirth: actor.value?.penisGirth?.imperial || null,
isCircumcised: actor.value.isCircumcised, isCircumcised: actor.value?.isCircumcised || null,
}, },
}, },
{ {
key: 'dateOfDeath', key: 'dateOfDeath',
label: 'date of death', label: 'date of death',
type: 'date', type: 'date',
value: actor.value.dateOfDeath value: actor.value?.dateOfDeath
? format(actor.value.dateOfDeath, 'yyyy-MM-dd') ? format(actor.value.dateOfDeath, 'yyyy-MM-dd')
: null, : null,
}, },
]); {
key: 'allowGlobalMatch',
label: 'global match',
type: 'has',
note: 'Allow this actor to be assigned to scenes automatically, overriding single-name protections.',
hasDescription: false,
value: {
has: actor.value?.isGlobal || null,
},
},
]
.filter((field) => (actor.value ? true : ['name', 'gender'].includes(field.key)))
.map((field) => ({
...field,
forced: actor.value ? field.forced : true,
})));
const editing = ref(new Set()); const editing = ref(new Set(actor.value ? [] : ['name', 'gender']));
const edits = ref(Object.fromEntries(fields.value.map((field) => [field.key, field.value]))); const edits = ref(Object.fromEntries(fields.value.map((field) => [field.key, field.value])));
const creating = !actor.value;
const comment = ref(null); const comment = ref(null);
const apply = ref(user.role !== 'user'); const apply = ref(user.role !== 'user');
const submitting = ref(false); const submitting = ref(false);
@@ -609,13 +639,54 @@ const groupMap = {
weight: 'size', weight: 'size',
}; };
async function submitCreate() {
submitting.value = true;
try {
const newActor = {
actor: Object.fromEntries(Array.from(['name', 'gender']).flatMap((key) => {
if (!edits.value[key]) {
throw new Error(`Missing ${key}`);
}
if (edits.value[key] && typeof edits.value[key] === 'object' && !Array.isArray(edits.value[key])) {
return Object.entries(edits.value[key]).map(([valueKey, value]) => [keyMap[key]?.[valueKey] || valueKey, value]);
}
return [[key, edits.value[key]]];
})),
comment: comment.value,
};
const { actor: createdActor } = await post('/actors', newActor, {
successFeedback: 'Actor has been added.',
appendErrorMessage: true,
});
actor.value = createdActor;
} catch (error) {
events.emit('feedback', {
type: 'error',
message: error.message,
});
}
submitting.value = false;
submitted.value = true;
}
async function submit() { async function submit() {
if (!actor.value) {
submitCreate();
return;
}
try { try {
submitting.value = true; submitting.value = true;
await post('/revisions/actors', { await post('/revisions/actors', {
actorId: actor.value.id, actorId: actor.value.id,
edits: { edits: omit({
...Object.fromEntries(Array.from(editing.value).flatMap((key) => { ...Object.fromEntries(Array.from(editing.value).flatMap((key) => {
if (edits.value[key] && typeof edits.value[key] === 'object' && !Array.isArray(edits.value[key])) { if (edits.value[key] && typeof edits.value[key] === 'object' && !Array.isArray(edits.value[key])) {
return Object.entries(edits.value[key]).map(([valueKey, value]) => [keyMap[key]?.[valueKey] || valueKey, value]); return Object.entries(edits.value[key]).map(([valueKey, value]) => [keyMap[key]?.[valueKey] || valueKey, value]);
@@ -629,15 +700,16 @@ async function submit() {
penisLength: penisUnits.value === 'imperial' ? edits.value.penis.imperialLength : edits.value.penis.metricLength, penisLength: penisUnits.value === 'imperial' ? edits.value.penis.imperialLength : edits.value.penis.metricLength,
penisGirth: penisUnits.value === 'imperial' ? edits.value.penis.imperialGirth : edits.value.penis.metricGirth, penisGirth: penisUnits.value === 'imperial' ? edits.value.penis.imperialGirth : edits.value.penis.metricGirth,
}).filter(([key]) => editing.value.has(groupMap[key] || key))), }).filter(([key]) => editing.value.has(groupMap[key] || key))),
metricHeight: undefined, }, [
metricWeight: undefined, 'metricHeight',
imperialHeight: undefined, 'metricWeight',
imperialWeight: undefined, 'imperialHeight',
metricLength: undefined, 'imperialWeight',
metricGirth: undefined, 'metricLength',
imperialLength: undefined, 'metricGirth',
imperialGirth: undefined, 'imperialLength',
}, 'imperialGirth',
]),
sizeUnits: sizeUnits.value, sizeUnits: sizeUnits.value,
figureUnits: figureUnits.value, figureUnits: figureUnits.value,
penisUnits: penisUnits.value, penisUnits: penisUnits.value,

View File

@@ -0,0 +1,34 @@
import { redirect } from 'vike/abort'; /* eslint-disable-line import/extensions */
import { fetchActorsById } from '#/src/actors.js';
import { fetchCountries } from '#/src/countries.js';
export async function onBeforeRender(pageContext) {
if (!pageContext.user) {
throw redirect(`/login?r=${encodeURIComponent(pageContext.urlOriginal)}`);
}
const [actor] = pageContext.routeParams.actorId
? await fetchActorsById([Number(pageContext.routeParams.actorId)], {}, pageContext.user)
: [];
const countries = await fetchCountries();
/*
if (!actor) {
throw render(404, `Cannot find actor '${pageContext.routeParams.actorId}'.`);
}
*/
return {
pageContext: {
title: actor
? `Editing '${actor.name}'`
: 'Adding actor',
pageProps: {
actor,
countries,
},
},
};
}

View File

@@ -0,0 +1,20 @@
// export default '/actor/edit/@actorId/*';
// import { redirect } from 'vike/abort'; /* eslint-disable-line import/extensions */
import { match } from 'path-to-regexp';
const path = '/actor/(edit|new)/:actorId?/:actorSlug?';
const urlMatch = match(path, { decode: decodeURIComponent });
export default (pageContext) => {
const matched = urlMatch(pageContext.urlPathname);
if (matched) {
return {
routeParams: matched.params.actorId ? {
actorId: matched.params.actorId,
} : {},
};
}
return false;
};

View File

@@ -44,7 +44,7 @@
</a> </a>
<a <a
v-if="user?.abilities?.some((ability) => ability.plainUrls)" v-if="user?.abilities?.some((ability) => ability.subject === 'plainUrls')"
:href="entity.url" :href="entity.url"
target="_blank" target="_blank"
rel="noopener" rel="noopener"

View File

@@ -57,19 +57,23 @@ export async function onBeforeRender(pageContext) {
fetchReleases(pageContext, entityId), fetchReleases(pageContext, entityId),
]); ]);
const entityIds = entity.isIndependent || !entity.parent
? [entity.id]
: [entity.id, entity.parent.id];
const campaigns = await getRandomCampaigns([ const campaigns = await getRandomCampaigns([
{ {
entityIds: [entity.id, entity.parent?.id].filter(Boolean), entityIds,
minRatio: 3, minRatio: 3,
allowRandomFallback: false, allowRandomFallback: false,
}, },
{ {
entityIds: [entity.id, entity.parent?.id].filter(Boolean), entityIds,
minRatio: 3, minRatio: 3,
allowRandomFallback: false, allowRandomFallback: false,
}, },
pageContext.routeParams.domain === 'scenes' ? { pageContext.routeParams.domain === 'scenes' ? {
entityIds: [entity.id, entity.parent?.id].filter(Boolean), entityIds,
minRatio: 0.75, minRatio: 0.75,
maxRatio: 1.25, maxRatio: 1.25,
allowRandomFallback: false, allowRandomFallback: false,

View File

@@ -52,7 +52,7 @@
</div> </div>
<Link <Link
:href="user?.abilities?.some((ability) => ability.plainUrls) ? scene.url : scene.watchUrl" :href="user?.abilities?.some((ability) => ability.subject === 'plainUrls') ? scene.url : scene.watchUrl"
:title="scene.date ? formatDate(scene.date.toISOString(), 'y-MM-dd hh:mm') : `Release date unknown, added ${formatDate(scene.createdAt, 'y-MM-dd')}`" :title="scene.date ? formatDate(scene.date.toISOString(), 'y-MM-dd hh:mm') : `Release date unknown, added ${formatDate(scene.createdAt, 'y-MM-dd')}`"
target="_blank" target="_blank"
class="date nolink" class="date nolink"

View File

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

View File

@@ -73,7 +73,7 @@
v-if="item.note" v-if="item.note"
v-tooltip="item.note" v-tooltip="item.note"
icon="info2" icon="info2"
class="item-note" class="item-note noselect"
/> />
</div> </div>
@@ -81,6 +81,7 @@
<Icon <Icon
v-if="!item.forced" v-if="!item.forced"
icon="pencil5" icon="pencil5"
class="noselect"
:class="{ active: editing.has(item.key) }" :class="{ active: editing.has(item.key) }"
@click="toggleField(item)" @click="toggleField(item)"
/> />
@@ -134,6 +135,15 @@
:disabled="!editing.has(item.key)" :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 <div
v-if="item.type === 'date'" v-if="item.type === 'date'"
class="date" class="date"
@@ -210,9 +220,10 @@
<div class="editor-actions"> <div class="editor-actions">
<Checkbox <Checkbox
v-if="user.role !== 'user'" v-if="user.role !== 'user'"
v-tooltip="isApplyDisabled && editing.has('delete') ? 'Delete must be approved by an admin' : null"
label="Approve and apply immediately" label="Approve and apply immediately"
:checked="apply" :checked="apply"
:disabled="editing.size === 0" :disabled="isApplyDisabled"
@change="(checked) => apply = checked" @change="(checked) => apply = checked"
/> />
@@ -241,10 +252,7 @@ import EditTags from '#/components/edit/tags.vue';
import EditMovies from '#/components/edit/movies.vue'; import EditMovies from '#/components/edit/movies.vue';
import Checkbox from '#/components/form/checkbox.vue'; import Checkbox from '#/components/form/checkbox.vue';
import { import { post } from '#/src/api.js';
// get,
post,
} from '#/src/api.js';
const pageContext = inject('pageContext'); const pageContext = inject('pageContext');
@@ -310,12 +318,20 @@ const fields = computed(() => [
}, },
...(user.role === 'user' ...(user.role === 'user'
? [] ? []
: [{ : [
{
key: 'comment', key: 'comment',
type: 'text', 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.', 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, value: scene.value.comment,
}]), },
{
key: 'delete',
type: 'checkbox',
checkboxLabel: 'Remove this scene from the database',
value: false,
},
]),
]); ]);
function simplifyArray(field) { function simplifyArray(field) {
@@ -332,6 +348,9 @@ const comment = ref(null);
const apply = ref(user.role !== 'user'); const apply = ref(user.role !== 'user');
const submitted = ref(false); 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 = { const keyMap = {
date: { date: {
date: 'date', date: 'date',
@@ -359,6 +378,14 @@ function setDuration(unit, event) {
edits.value.duration[timeUnits.indexOf(unit)] = Number(event.target.value); 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() { async function submit() {
try { try {
await post('/revisions/scenes', { await post('/revisions/scenes', {
@@ -417,6 +444,10 @@ async function submit() {
display: flex; display: flex;
align-items: center; align-items: center;
padding: .25rem 1rem; padding: .25rem 1rem;
.value.disabled {
color: var(--glass);
}
} }
.key { .key {
@@ -488,7 +519,7 @@ async function submit() {
} }
} }
.item-note{ .item-note {
fill: var(--glass); fill: var(--glass);
padding: .5rem .75rem; padding: .5rem .75rem;
cursor: help; 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 { .editor-actions {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -540,6 +590,27 @@ async function submit() {
line-height: 1.5; 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) { @media(--small) {
.row { .row {
flex-direction: column; 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" /> <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>`) : ''} ${config.analytics.enabled ? dangerouslySkipEscape(`<script src="${config.analytics.address}" data-website-id="${config.analytics.siteId}" data-exclude-hash="true" async></script>`) : ''}
<title>${title}</title> <title>${title}</title>

View File

@@ -3,6 +3,7 @@ import { differenceInYears } from 'date-fns';
import { unit } from 'mathjs'; import { unit } from 'mathjs';
import { MerkleJson } from 'merkle-json'; import { MerkleJson } from 'merkle-json';
import moment from 'moment'; import moment from 'moment';
import { nanoid } from 'nanoid';
import omit from 'object.omit'; import omit from 'object.omit';
import convert from 'convert'; import convert from 'convert';
import unprint from 'unprint'; import unprint from 'unprint';
@@ -21,6 +22,8 @@ import slugify from '../utils/slugify.js';
import { curateRevision } from './revisions.js'; import { curateRevision } from './revisions.js';
import { interpolateProfiles, platformsByHostname } from '../common/actors.mjs'; // eslint-disable-line import/namespace import { interpolateProfiles, platformsByHostname } from '../common/actors.mjs'; // eslint-disable-line import/namespace
import { resolvePlace } from '../common/geo.mjs'; // eslint-disable-line import/namespace import { resolvePlace } from '../common/geo.mjs'; // eslint-disable-line import/namespace
import { syncScenes, syncActors } from './sync.js';
import verifyAbility from '../utils/verify-ability.js';
const logger = initLogger(); const logger = initLogger();
const mj = new MerkleJson(); const mj = new MerkleJson();
@@ -55,7 +58,7 @@ const keyMap = {
isCircumcised: 'circumcised', 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 = {}) { export function curateActor(actor, context = {}) {
return { return {
@@ -80,6 +83,8 @@ export function curateActor(actor, context = {}) {
ageThen: context.sceneDate && actor.date_of_birth && actor.date_of_birth.getFullYear() > 1 ageThen: context.sceneDate && actor.date_of_birth && actor.date_of_birth.getFullYear() > 1
? differenceInYears(context.sceneDate, actor.date_of_birth) ? differenceInYears(context.sceneDate, actor.date_of_birth)
: null, : null,
dateOfBirth: actor.date_of_birth,
dateOfDeath: actor.date_of_death,
bust: actor.bust, bust: actor.bust,
cup: actor.cup, cup: actor.cup,
waist: actor.waist, waist: actor.waist,
@@ -370,6 +375,10 @@ async function queryManticoreSql(filters, options, _reqUser) {
} }
} }
if (filters.isGlobal) {
builder.where('entity_id', 0);
}
// attribute filters // attribute filters
['country'].forEach((attribute) => { ['country'].forEach((attribute) => {
if (filters[attribute]) { if (filters[attribute]) {
@@ -519,6 +528,102 @@ export async function fetchActors(filters, rawOptions, reqUser) {
}; };
} }
function curateActorEntry(actor, context) {
return {
name: actor.name,
slug: slugify(actor.name),
entry_id: nanoid(), // allows for manual creation of multiple actors with the same name
gender: actor.gender,
comment: context?.comment,
};
}
export async function createActor(newActor, context, reqUser) {
if (!reqUser || reqUser.role === 'user') {
throw new HttpError('You are not permitted to create actors', 403);
}
const curatedActorEntry = curateActorEntry(newActor, context);
const [actorEntry] = await knex('actors').insert(curatedActorEntry).returning('*');
await syncActors([actorEntry.id]);
return curateActor(actorEntry);
}
export async function mergeActors(targetActorId, sourceActorId, reqUser) {
if (!verifyAbility(reqUser, 'actor', 'merge')) {
throw new HttpError('You are not permitted to merge actors', 403);
}
const [targetActor, sourceActor] = await Promise.all([
knex('actors')
.where('id', targetActorId)
.first(),
knex('actors')
.where('id', sourceActorId)
.first(),
]);
if (!targetActor) {
throw new HttpError('Target actor not found', 404);
}
if (!sourceActor) {
throw new HttpError('Source actor not found', 404);
}
if (targetActor.entity_id) {
throw new HttpError('Target actor is not global', 400);
}
if (targetActor.alias_for) {
throw new HttpError('Target actor is aliased', 400);
}
const trx = await knex.transaction();
await trx('actors')
.update('alias_for', targetActorId)
.where('id', sourceActorId);
const mergedProfiles = await trx('actors_profiles')
.update('actor_id', targetActorId)
.where('actor_id', sourceActorId)
.returning('id');
const mergedScenes = await trx('releases_actors')
.update({
actor_id: targetActorId,
alias_id: sourceActorId,
})
.where('actor_id', sourceActorId)
.returning('release_id');
try {
await trx.commit();
} catch (error) {
await trx.rollback();
throw error;
}
await interpolateProfiles([targetActorId, sourceActorId], {
knex,
logger,
moment,
slugify,
omit,
}, { refreshView: false });
await syncScenes(mergedScenes.map((scene) => scene.release_id));
return {
scenes: mergedScenes.length,
profiles: mergedProfiles.length,
};
}
export async function fetchActorRevisions(revisionId, filters = {}, reqUser) { export async function fetchActorRevisions(revisionId, filters = {}, reqUser) {
const limit = filters.limit || 50; const limit = filters.limit || 50;
const page = filters.page || 1; const page = filters.page || 1;
@@ -723,6 +828,8 @@ async function applyActorRevision(revisionIds, reqUser) {
slugify, slugify,
omit, omit,
}, { refreshView: false }); }, { refreshView: false });
await syncActors(actorIds);
} }
export async function reviewActorRevision(revisionId, isApproved, { feedback }, reqUser) { export async function reviewActorRevision(revisionId, isApproved, { feedback }, reqUser) {

View File

@@ -6,7 +6,7 @@ function getWatchUrl(scene) {
return new URL(scene.url).href; 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; return new URL(scene.channel.url).href;
} }

View File

@@ -203,6 +203,8 @@ export async function verifyKey(userId, key, req) {
.then(() => { .then(() => {
// no need to wait for this // no need to wait for this
}); });
return fetchUser(storedKey.user_id);
} }
export async function createKey(reqUser) { export async function createKey(reqUser) {

View File

@@ -17,6 +17,7 @@ import initLogger from './logger.js';
import { curateRevision } from './revisions.js'; import { curateRevision } from './revisions.js';
import { getAffiliateSceneUrl } from './affiliates.js'; import { getAffiliateSceneUrl } from './affiliates.js';
import { censor } from './censor.js'; import { censor } from './censor.js';
import { syncScenes } from './sync.js';
const logger = initLogger(); const logger = initLogger();
const mj = new MerkleJson(); const mj = new MerkleJson();
@@ -45,6 +46,7 @@ function curateScene(rawScene, assets, reqUser, context) {
slug: assets.channel.slug, slug: assets.channel.slug,
name: censor(assets.channel.name, context.restriction), name: censor(assets.channel.name, context.restriction),
type: assets.channel.type, type: assets.channel.type,
url: assets.channel.url,
isIndependent: assets.channel.independent, isIndependent: assets.channel.independent,
hasLogo: assets.channel.has_logo, hasLogo: assets.channel.has_logo,
}, },
@@ -52,6 +54,7 @@ function curateScene(rawScene, assets, reqUser, context) {
id: assets.channel.network_id, id: assets.channel.network_id,
slug: assets.channel.network_slug, slug: assets.channel.network_slug,
name: censor(assets.channel.network_name, context.restriction), name: censor(assets.channel.network_name, context.restriction),
url: assets.network_url,
type: assets.channel.network_type, type: assets.channel.network_type,
hasLogo: assets.channel.network_has_logo, hasLogo: assets.channel.network_has_logo,
} : null, } : null,
@@ -179,6 +182,7 @@ export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) {
'networks.slug as network_slug', 'networks.slug as network_slug',
'networks.name as network_name', 'networks.name as network_name',
'networks.type as network_type', 'networks.type as network_type',
'networks.url as network_url',
'networks.has_logo as network_has_logo', 'networks.has_logo as network_has_logo',
knex.raw('row_to_json(affiliates) as affiliate'), knex.raw('row_to_json(affiliates) as affiliate'),
) )
@@ -402,10 +406,11 @@ function curateOptions(options) {
}; };
} }
function curateFacet(results, field, count = 'count(distinct id)') { // function curateFacet(results, field, count = 'count(distinct id)') {
function curateFacet(results, field) {
return results return results
.find((result) => result.columns[0][field] && result.columns[1][count]) .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] })) ?.data.map((row) => ({ key: row[field], doc_count: row['count(distinct id)'] || row['count(*)'] }))
.filter((row) => !!row.key) .filter((row) => !!row.key)
|| []; || [];
} }
@@ -432,6 +437,7 @@ async function queryManticoreSql(filters, options, _reqUser) {
:yearsFacet: :yearsFacet:
:actorsFacet: :actorsFacet:
:tagsFacet: :tagsFacet:
:actorTagsFacet:
:channelsFacet: :channelsFacet:
:studiosFacet:; :studiosFacet:;
show meta; show meta;
@@ -465,15 +471,14 @@ async function queryManticoreSql(filters, options, _reqUser) {
weight() as _score weight() as _score
`)); `));
/* // manticore only supports one joined table, so we can't use it inside stashes
// manticore only supports one joined table, so we can't use it inside stashes; probably not needed anyway (stashes only need global tags?)
builder builder
.leftJoin('scenes_tags', 'scenes_tags.scene_id', 'scenes_.id') .leftJoin('scenes_tags', 'scenes_tags.scene_id', 'scenes_.id')
.groupBy('scenes.id'); .groupBy('scenes.id');
*/
} }
if (filters.query) { 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) }); builder.whereRaw('match(\'@!title :query:\', scenes)', { query: escape(filters.query) });
} }
@@ -574,16 +579,14 @@ async function queryManticoreSql(filters, options, _reqUser) {
.offset((options.page - 1) * options.limit), .offset((options.page - 1) * options.limit),
// 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(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 // don't facet tags associated to other actors, actor ID 0 means global
tagsFacet: options.aggregateTags ? knex.raw('facet scenes.tag_ids as tags_facet order by count(distinct id) 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 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(distinct id) desc limit ?`, [aggSize]) ? 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, : 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(distinct id) 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(distinct id) desc limit ?', [aggSize]) : null,
maxMatches: config.database.manticore.maxMatches, maxMatches: config.database.manticore.maxMatches,
maxQueryTime: config.database.manticore.maxQueryTime, maxQueryTime: config.database.manticore.maxQueryTime,
}).toString(); }).toString();
@@ -601,9 +604,7 @@ async function queryManticoreSql(filters, options, _reqUser) {
const results = await utilsApi.sql(curatedSqlQuery); const results = await utilsApi.sql(curatedSqlQuery);
// console.log(util.inspect(results, null, Infinity)); const years = curateFacet(results, 'years_facet');
const years = curateFacet(results, 'years_facet', 'count(*)');
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 actorTagIds = curateFacet(results, 'actor_tags_facet');
@@ -729,8 +730,14 @@ export async function fetchSceneRevisions(revisionId, filters = {}, reqUser) {
.limit(limit) .limit(limit)
.offset((page - 1) * 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 actorIds = Array.from(new Set(revisions.flatMap((revision) => [
const tagIds = Array.from(new Set(revisions.flatMap((revision) => [...revision.base.tags, ...(revision.deltas.find((delta) => delta.key === 'tags')?.value || [])]))); ...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 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([ const [actors, tags, movies] = await Promise.all([
@@ -817,7 +824,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') const revisions = await knexOwner('scenes_revisions')
.whereIn('id', revisionIds) .whereIn('id', revisionIds)
.whereNull('applied_at'); // should not re-apply revision that was already applied .whereNull('applied_at'); // should not re-apply revision that was already applied
@@ -827,6 +845,10 @@ async function applySceneRevision(revisionIds) {
await knexOwner.transaction(async (trx) => { await knexOwner.transaction(async (trx) => {
await Promise.all(revision.deltas.map(async (delta) => { await Promise.all(revision.deltas.map(async (delta) => {
if (delta.key === 'delete') {
return applySceneDeleteDelta(revision.scene_id, delta, trx, reqUser);
}
if ([ if ([
'title', 'title',
'description', 'description',
@@ -858,13 +880,19 @@ async function applySceneRevision(revisionIds) {
await knexOwner('scenes_revisions') await knexOwner('scenes_revisions')
.where('id', revision.id) .where('id', revision.id)
.update('applied_at', knex.fn.now()); .update('applied_at', knexOwner.fn.now())
.transacting(trx);
// await trx.commit(); // await trx.commit();
}).catch(async (error) => { }).catch(async (error) => {
logger.error(`Failed to apply revision ${revision.id} on scene ${revision.scene_id}: ${error.message}`); logger.error(`Failed to apply revision ${revision.id} on scene ${revision.scene_id}: ${error.message}`);
throw error;
}); });
}, Promise.resolve()); }, Promise.resolve());
const sceneIds = Array.from(new Set(revisions.map((revision) => revision.scene_id)));
await syncScenes(sceneIds);
} }
export async function reviewSceneRevision(revisionId, isApproved, { feedback }, reqUser) { export async function reviewSceneRevision(revisionId, isApproved, { feedback }, reqUser) {
@@ -892,11 +920,27 @@ export async function reviewSceneRevision(revisionId, isApproved, { feedback },
} }
if (isApproved) { 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;
}
} }
} }
export async function createSceneRevision(sceneId, { edits, comment, apply }, reqUser) { export async function createSceneRevision(sceneId, { edits, comment, apply }, reqUser) {
if (!reqUser) {
throw new HttpError('Must be authenticated to create scene revision', 401);
}
const [ const [
[scene], [scene],
openRevisions, openRevisions,
@@ -938,6 +982,13 @@ export async function createSceneRevision(sceneId, { edits, comment, apply }, re
return [key, values.id]; return [key, values.id];
} }
if (key === 'tags') {
return [key, values.map((tag) => ({
id: tag.id,
actorId: tag.actorId,
}))];
}
if (Array.isArray(values)) { if (Array.isArray(values)) {
return [key, values.map((value) => value?.hash || value?.id || value)]; return [key, values.map((value) => value?.hash || value?.id || value)];
} }
@@ -946,10 +997,24 @@ export async function createSceneRevision(sceneId, { edits, comment, apply }, re
}).filter(Boolean)); }).filter(Boolean));
const deltas = Object.entries(edits).map(([key, value]) => { const deltas = Object.entries(edits).map(([key, value]) => {
if (key === 'delete') {
return { key: 'delete' };
}
if (baseScene[key] === value || typeof value === 'undefined') { if (baseScene[key] === value || typeof value === 'undefined') {
return null; return null;
} }
if (key === 'tags') {
return {
key,
value: value.map((tag) => ({
id: tag.id,
actorId: tag.actorId,
})),
};
}
if (Array.isArray(value)) { if (Array.isArray(value)) {
const valueSet = new Set(value); const valueSet = new Set(value);
const baseSet = new Set(baseScene[key]); const baseSet = new Set(baseScene[key]);
@@ -984,6 +1049,6 @@ export async function createSceneRevision(sceneId, { edits, comment, apply }, re
if (['admin', 'editor'].includes(reqUser.role) && apply) { if (['admin', 'editor'].includes(reqUser.role) && apply) {
// don't keep the editor waiting for the revision to 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

@@ -200,6 +200,10 @@ export async function createStash(newStash, sessionUser) {
throw new HttpError('You are not authenthicated', 401); throw new HttpError('You are not authenthicated', 401);
} }
if (!newStash) {
throw new HttpError('Missing new stash', 400);
}
verifyStashName(newStash); verifyStashName(newStash);
try { try {
@@ -224,6 +228,14 @@ export async function updateStash(stashIdOrSlug, updatedStash, sessionUser) {
throw new HttpError('You are not authenthicated', 401); throw new HttpError('You are not authenthicated', 401);
} }
if (!stashIdOrSlug) {
throw new HttpError('Missing stash ID or slug', 400);
}
if (!updatedStash) {
throw new HttpError('Missing updated stash', 400);
}
if (updatedStash.name) { if (updatedStash.name) {
verifyStashName(updatedStash); verifyStashName(updatedStash);
} }
@@ -260,6 +272,10 @@ export async function removeStash(stashId, sessionUser) {
throw new HttpError('You are not authenthicated', 401); throw new HttpError('You are not authenthicated', 401);
} }
if (!stashId) {
throw new HttpError('Missing stash ID', 400);
}
const stash = await fetchStashById(stashId, sessionUser); const stash = await fetchStashById(stashId, sessionUser);
if (!stash) { if (!stash) {
@@ -283,6 +299,14 @@ export async function removeStash(stashId, sessionUser) {
} }
export async function stashActor(actorId, stashId, sessionUser) { export async function stashActor(actorId, stashId, sessionUser) {
if (!actorId) {
throw new HttpError('Missing actor ID', 400);
}
if (!stashId) {
throw new HttpError('Missing stash ID', 400);
}
const stash = await fetchStashById(stashId, sessionUser); const stash = await fetchStashById(stashId, sessionUser);
if (!stash) { if (!stash) {
@@ -324,6 +348,14 @@ export async function stashActor(actorId, stashId, sessionUser) {
} }
export async function unstashActor(actorId, stashId, sessionUser) { export async function unstashActor(actorId, stashId, sessionUser) {
if (!actorId) {
throw new HttpError('Missing actor ID', 400);
}
if (!stashId) {
throw new HttpError('Missing stash ID', 400);
}
const stash = await fetchStashById(stashId, sessionUser); const stash = await fetchStashById(stashId, sessionUser);
if (!stash) { if (!stash) {
@@ -367,6 +399,14 @@ export async function unstashActor(actorId, stashId, sessionUser) {
} }
export async function stashScene(sceneId, stashId, sessionUser) { export async function stashScene(sceneId, stashId, sessionUser) {
if (!sceneId) {
throw new HttpError('Missing scene ID', 400);
}
if (!stashId) {
throw new HttpError('Missing stash ID', 400);
}
const stash = await fetchStashById(stashId, sessionUser); const stash = await fetchStashById(stashId, sessionUser);
if (!stash) { if (!stash) {
@@ -409,6 +449,14 @@ export async function stashScene(sceneId, stashId, sessionUser) {
} }
export async function unstashScene(sceneId, stashId, sessionUser) { export async function unstashScene(sceneId, stashId, sessionUser) {
if (!sceneId) {
throw new HttpError('Missing scene ID', 400);
}
if (!stashId) {
throw new HttpError('Missing stash ID', 400);
}
const stash = await fetchStashById(stashId, sessionUser); const stash = await fetchStashById(stashId, sessionUser);
if (!stash) { if (!stash) {
@@ -448,6 +496,14 @@ export async function unstashScene(sceneId, stashId, sessionUser) {
} }
export async function stashMovie(movieId, stashId, sessionUser) { export async function stashMovie(movieId, stashId, sessionUser) {
if (!movieId) {
throw new HttpError('Missing movie ID', 400);
}
if (!stashId) {
throw new HttpError('Missing stash ID', 400);
}
const stash = await fetchStashById(stashId, sessionUser); const stash = await fetchStashById(stashId, sessionUser);
if (!stash) { if (!stash) {
@@ -489,6 +545,14 @@ export async function stashMovie(movieId, stashId, sessionUser) {
} }
export async function unstashMovie(movieId, stashId, sessionUser) { export async function unstashMovie(movieId, stashId, sessionUser) {
if (!movieId) {
throw new HttpError('Missing movie ID', 400);
}
if (!stashId) {
throw new HttpError('Missing stash ID', 400);
}
const stash = await fetchStashById(stashId, sessionUser); const stash = await fetchStashById(stashId, sessionUser);
if (!stash) { if (!stash) {

440
src/sync.js Normal file
View File

@@ -0,0 +1,440 @@
import { format } from 'date-fns';
import initLogger from './logger.js';
import { knexOwner as knex } from './knex.js';
import { searchApi, indexApi } from './manticore.js';
import chunk from '../utils/chunk.js';
import filterTitle from '../utils/filter-title.js';
const logger = initLogger();
export async function syncStashes(domain = 'scene', ids) {
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`,
)
.modify((builder) => {
if (ids) {
builder.whereRaw(`stashes_${domain}s.${domain}_id = ANY(?)`, [ids]);
}
})
.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'));
logger.verbose(`Seeded ${index * 1000 + stashChunk.length}/${stashes.length} ${domain} stashes`);
}, Promise.resolve());
// purge orphaned docs
const itemIds = ids ?? [...new Set(stashes.map((s) => s[`${domain}_id`]))];
if (itemIds.length === 0) {
return;
}
const validStashedIds = new Set(stashes.map((stash) => stash.stashed_id));
await chunk(itemIds, 1000).reduce(async (chain, itemIdChunk) => {
await chain;
const searchResponse = await searchApi.search({
index: `${domain}s_stashed`,
query: {
in: {
[`${domain}_id`]: itemIdChunk,
},
},
limit: 1000,
});
const docs = searchResponse?.hits?.hits ?? [];
const orphanedIds = docs
.map((hit) => hit._id)
.filter((manticoreId) => !validStashedIds.has(manticoreId));
if (orphanedIds.length === 0) {
return;
}
const deleteDocs = orphanedIds.map((orphanId) => ({
delete: {
index: `${domain}s_stashed`,
id: orphanId,
},
}));
await indexApi.bulk(deleteDocs.map((doc) => JSON.stringify(doc)).join('\n'));
logger.verbose(`Purged ${orphanedIds.length} orphaned ${domain} stash documents`);
}, Promise.resolve());
}
export async function syncManticoreScenes(sceneIds) {
logger.info(`Updating Manticore search documents for ${sceneIds ? sceneIds.length : 'all' } scenes`);
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,
studios.id as studio_id,
studios.slug as studio_slug,
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 (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,
studios.showcased IS NOT false
AND (entities.showcased IS NOT false OR COALESCE(studios.showcased, false) = true)
AND (parents.showcased IS NOT false OR COALESCE(entities.showcased, false) = true OR COALESCE(studios.showcased, false) = true)
AND (releases_summaries.batch_showcased IS NOT false)
AS showcased,
row_number() OVER (PARTITION BY releases.entry_id, parents.id ORDER BY releases.effective_date DESC) as dupe_index
FROM releases
LEFT JOIN releases_summaries ON releases_summaries.release_id = releases.id
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 entities AS grandparents ON grandparents.id = parents.parent_id
LEFT JOIN entities AS studios ON studios.id = releases.studio_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
LEFT JOIN movies_scenes ON movies_scenes.scene_id = releases.id
LEFT JOIN movies ON movies.id = movies_scenes.movie_id
LEFT JOIN series_scenes ON series_scenes.scene_id = releases.id
LEFT JOIN series ON series.id = series_scenes.serie_id
${sceneIds ? 'WHERE releases.id = ANY(?)' : ''}
GROUP BY
releases.id,
releases.title,
releases.created_at,
releases.date,
releases.shoot_id,
scenes_meta.stashed,
releases_summaries.batch_showcased,
entities.id,
entities.name,
entities.slug,
entities.alias,
entities.showcased,
parents.id,
parents.name,
parents.slug,
parents.alias,
grandparents.id,
studios.id,
studios.name,
studios.slug,
parents.showcased,
studios.showcased
`, sceneIds && [sceneIds]);
const scenesById = Object.fromEntries(scenes.rows.map((scene) => [scene.id, scene]));
const docs = (sceneIds || Object.keys(scenesById)).map((sceneId) => {
const scene = scenesById[sceneId];
if (!scene) {
return {
delete: {
index: 'scenes',
id: sceneId,
},
};
}
const flatActors = scene.actors.flatMap((actor) => actor.f2.split(' '));
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]);
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),
is_showcased: scene.showcased,
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,
studio_id: scene.studio_id || undefined,
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(),
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),
movies: scene.movies.map((movie) => movie.f2).join(' '),
serie_ids: scene.series.map((serie) => serie.f1),
series: scene.series.map((serie) => serie.f2).join(' '),
meta: scene.date ? format(scene.date, 'y yy M MMM MMMM d') : undefined,
stashed: scene.stashed || 0,
dupe_index: scene.dupe_index || 0,
},
},
};
});
if (docs.length === 0) {
return null;
}
const [manticoreResult] = await Promise.all([
chunk(docs, 10000).reduce(async (chain, docsChunk, index, array) => {
const acc = await chain;
const data = await indexApi.bulk(docsChunk.map((doc) => JSON.stringify(doc)).join('\n'));
logger.verbose(`Seeded ${index + 1}/${array.length}, errors: ${data.errors} ${data.error}`);
return acc.concat(data.items);
}, Promise.resolve([])),
syncStashes('scene', sceneIds),
]);
return manticoreResult;
}
export async function syncScenes(releaseIds) {
await knex.raw('REFRESH MATERIALIZED VIEW scenes_meta;');
await syncManticoreScenes(releaseIds);
}
export async function syncManticoreMovies(movieIds) {
logger.info(`Updating Manticore search documents for ${movieIds ? movieIds.length : 'all' } movies`);
const movies = await knex.raw(`
SELECT
movies.id AS id,
movies.title,
movies.created_at,
movies.date,
movies_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,
movies_covers IS NOT NULL as has_cover,
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 (movie_tags.id, movie_tags.name, movie_tags.priority, movie_tags_aliases.name)) FILTER (WHERE movie_tags.id IS NOT NULL), '[]') as movie_tags,
row_number() OVER (PARTITION BY movies.entry_id, parents.id ORDER BY movies.effective_date DESC) as dupe_index
FROM movies
LEFT JOIN movies_meta ON movies_meta.movie_id = movies.id
LEFT JOIN movies_scenes ON movies_scenes.movie_id = movies.id
LEFT JOIN movies_tags ON movies_tags.movie_id = movies.id
LEFT JOIN entities ON movies.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 = movies_scenes.scene_id
LEFT JOIN releases_directors AS local_directors ON local_directors.release_id = movies_scenes.scene_id
LEFT JOIN releases_tags AS local_tags ON local_tags.release_id = movies_scenes.scene_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
LEFT JOIN tags as movie_tags ON movies_tags.tag_id = movie_tags.id
LEFT JOIN tags as movie_tags_aliases ON movies_tags.tag_id = movie_tags_aliases.alias_for AND movie_tags_aliases.secondary = true
LEFT JOIN movies_covers ON movies_covers.movie_id = movies.id
${movieIds ? 'WHERE movies.id = ANY(?)' : ''}
GROUP BY
movies.id,
movies.title,
movies.created_at,
movies.date,
movies_meta.stashed,
movies_meta.stashed_scenes,
movies_meta.stashed_total,
entities.id,
entities.name,
entities.slug,
entities.alias,
parents.id,
parents.name,
parents.slug,
parents.alias,
movies_covers.*
`, movieIds && [movieIds]);
const moviesById = Object.fromEntries(movies.rows.map((movie) => [movie.id, movie]));
const docs = (movieIds || Object.keys(moviesById)).map((movieId) => {
const movie = moviesById[movieId];
if (!movie) {
return {
delete: {
index: 'movies',
id: movieId,
},
};
}
const combinedTags = Object.values(Object.fromEntries(movie.tags.concat(movie.movie_tags).map((tag) => [tag.f1, {
id: tag.f1,
name: tag.f2,
priority: tag.f3,
alias: tag.f4,
}])));
const flatActors = movie.actors.flatMap((actor) => actor.f2.match(/[\w']+/g)); // match word characters to filter out brackets etc.
const flatTags = combinedTags.filter((tag) => tag.priority > 6).flatMap((tag) => (tag.alias ? `${tag.name} ${tag.alias}` : tag.name).match(/[\w']+/g)); // only make top tags searchable to minimize cluttered results
const filteredTitle = movie.title && [...flatActors, ...flatTags].reduce((accTitle, tag) => accTitle.replace(new RegExp(tag.replace(/[^\w\s]+/g, ''), 'gi'), ''), movie.title).trim().replace(/\s{2,}/g, ' ');
return {
replace: {
index: 'movies',
id: movie.id,
doc: {
title: movie.title || undefined,
title_filtered: filteredTitle || undefined,
date: movie.date ? Math.round(movie.date.getTime() / 1000) : undefined,
created_at: Math.round(movie.created_at.getTime() / 1000),
effective_date: Math.round((movie.date || movie.created_at).getTime() / 1000),
channel_id: movie.channel_id,
channel_slug: movie.channel_slug,
channel_name: movie.channel_name,
network_id: movie.network_id || undefined,
network_slug: movie.network_slug || undefined,
network_name: movie.network_name || undefined,
entity_ids: [movie.channel_id, movie.network_id].filter(Boolean), // manticore does not support OR, this allows IN
actor_ids: movie.actors.map((actor) => actor.f1),
actors: movie.actors.map((actor) => actor.f2).join(),
tag_ids: combinedTags.map((tag) => tag.id),
tags: flatTags.join(' '),
has_cover: movie.has_cover,
meta: movie.date ? format(movie.date, 'y yy M MMM MMMM d') : undefined,
stashed: movie.stashed || 0,
stashed_scenes: movie.stashed_scenes || 0,
stashed_total: movie.stashed_total || 0,
dupe_index: movie.dupe_index || 0,
},
},
};
});
if (docs.length === 0) {
return null;
}
return indexApi.bulk(docs.map((doc) => JSON.stringify(doc)).join('\n'));
}
export async function syncMovies(releaseIds) {
await knex.raw('REFRESH MATERIALIZED VIEW movies_meta;');
await syncManticoreMovies(releaseIds);
}
export async function syncManticoreActors(actorIds) {
logger.info(`Updating Manticore search documents for ${actorIds ? actorIds.length : 'all' } actors`);
// manually select date of birth, otherwise it is retrieved in local timezone but interpreted as UTC...
const actors = await knex.raw(`
SELECT
actors.*,
actors_meta.*,
date_of_birth AT TIME ZONE 'Europe/Amsterdam' AT TIME ZONE 'UTC' as dob
FROM actors
LEFT JOIN actors_meta ON actors_meta.actor_id = actors.id
${actorIds ? 'WHERE actors.id = ANY(?)' : ''}
`, actorIds && [actorIds]);
const actorsById = Object.fromEntries(actors.rows.map((actor) => [actor.id, actor]));
const docs = (actorIds || Object.keys(actorsById)).map((actorId) => {
const actor = actorsById[actorId];
if (!actor) {
return {
delete: {
index: 'actors',
id: actorId,
},
};
}
return {
replace: {
index: 'actors',
id: actor.id,
doc: {
entity_id: actor.entity_id,
name: actor.name,
slug: actor.slug,
gender: actor.gender || undefined,
date_of_birth: actor.dob ? Math.round(actor.dob.getTime() / 1000) : undefined,
has_avatar: !!actor.avatar_media_id,
country: actor.birth_country_alpha2 || undefined,
height: actor.height || undefined,
mass: actor.weight || undefined, // weight is a reserved keyword in manticore
cup: actor.cup || undefined,
natural_boobs: actor.natural_boobs === null ? 0 : Number(actor.natural_boobs) + 1, // manticore bool does not seem to support null, and we need three states for natural_boobs: yes, no and unknown
penis_length: actor.penis_length || undefined,
penis_girth: actor.penis_girth || undefined,
stashed: actor.stashed || 0,
scenes: actor.scenes || 0,
},
},
};
});
if (docs.length === 0) {
return null;
}
return indexApi.bulk(docs.map((doc) => JSON.stringify(doc)).join('\n'));
}
export async function syncActors(actorIds) {
await knex.raw('REFRESH MATERIALIZED VIEW actors_meta;');
await syncManticoreActors(actorIds);
}

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

@@ -1,77 +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 utilsApi.sql('drop table if exists scenes_stashed');
await utilsApi.sql(`create table if not exists scenes_stashed (
scene_id int,
stash_id int,
user_id int,
created_at timestamp
)`);
await utilsApi.sql('drop table if exists movies_stashed');
await utilsApi.sql(`create table if not exists movies_stashed (
movie_id int,
stash_id int,
user_id int,
created_at timestamp
)`);
await utilsApi.sql('drop table if exists actors_stashed');
await utilsApi.sql(`create table if not exists actors_stashed (
actor_id int,
stash_id int,
user_id int,
created_at timestamp
)`);
await syncStashes('scene');
await syncStashes('actor');
await syncStashes('movie');
console.log('Done!');
knex.destroy();
}
init();

View File

@@ -1,8 +1,11 @@
import Router from 'express-promise-router'; import Router from 'express-promise-router';
import omit from 'object.omit';
import { import {
fetchActors, fetchActors,
fetchActorsById, fetchActorsById,
createActor,
mergeActors,
fetchActorRevisions, fetchActorRevisions,
createActorRevision, createActorRevision,
reviewActorRevision, reviewActorRevision,
@@ -24,6 +27,7 @@ export function curateActorsQuery(query) {
weight: query.weight?.split(',').map((weight) => Number(weight)), weight: query.weight?.split(',').map((weight) => Number(weight)),
requireAvatar: query.avatar, requireAvatar: query.avatar,
stashId: Number(query.stashId) || null, stashId: Number(query.stashId) || null,
isGlobal: !!query.global,
}; };
} }
@@ -169,6 +173,18 @@ export async function fetchActorsByIdGraphql(query, _req, _info) {
return curatedActors[0]; return curatedActors[0];
} }
export async function createActorApi(req, res) {
const actor = await createActor(req.body.actor, omit(req.body, ['actor']), req.user);
res.send({ actor });
}
export async function mergeActorsApi(req, res) {
const result = await mergeActors(Number(req.params.targetActorId), Number(req.params.sourceActorId), req.user);
res.send(result);
}
async function fetchActorRevisionsApi(req, res) { async function fetchActorRevisionsApi(req, res) {
const revisions = await fetchActorRevisions(Number(req.params.revisionId) || null, req.query, req.user); const revisions = await fetchActorRevisions(Number(req.params.revisionId) || null, req.query, req.user);
@@ -190,6 +206,9 @@ async function reviewActorRevisionApi(req, res) {
export const actorsRouter = Router(); export const actorsRouter = Router();
actorsRouter.get('/api/actors', fetchActorsApi); actorsRouter.get('/api/actors', fetchActorsApi);
actorsRouter.post('/api/actors', createActorApi);
actorsRouter.post('/api/actors/:targetActorId/merge/:sourceActorId', mergeActorsApi);
actorsRouter.get('/api/revisions/actors', fetchActorRevisionsApi); actorsRouter.get('/api/revisions/actors', fetchActorRevisionsApi);
actorsRouter.get('/api/revisions/actors/:revisionId', fetchActorRevisionsApi); actorsRouter.get('/api/revisions/actors/:revisionId', fetchActorRevisionsApi);

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'); const redirect = req.headers.referer && new URL(req.headers.referer).searchParams.get('redirect');
if (Object.hasOwn(req.query, 'lgbt')) { 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 req.tagFilter = lgbtFilters; // eslint-disable-line no-param-reassign
res.cookie('tags', JSON.stringify(lgbtFilters)); res.cookie('tags', JSON.stringify(lgbtFilters));
} }
if (Object.hasOwn(req.query, 'straight')) { 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 req.tagFilter = straightFilters; // eslint-disable-line no-param-reassign
res.cookie('tags', JSON.stringify(straightFilters)); res.cookie('tags', JSON.stringify(straightFilters));

View File

@@ -16,6 +16,7 @@ import initRestrictionHandler from './restrictions.js';
import { scenesRouter } from './scenes.js'; import { scenesRouter } from './scenes.js';
import { actorsRouter } from './actors.js'; import { actorsRouter } from './actors.js';
import { syncRouter } from './sync.js';
import { fetchMoviesApi } from './movies.js'; import { fetchMoviesApi } from './movies.js';
import { fetchEntitiesApi } from './entities.js'; import { fetchEntitiesApi } from './entities.js';
@@ -122,11 +123,7 @@ export default async function initServer() {
router.use('/api/*', async (req, _res, next) => { router.use('/api/*', async (req, _res, next) => {
if (req.headers['api-user']) { if (req.headers['api-user']) {
await verifyKey(req.headers['api-user'], req.headers['api-key'], req); req.user = await verifyKey(req.headers['api-user'], req.headers['api-key'], req);
req.user = { // eslint-disable-line no-param-reassign
id: Number(req.headers['api-user']),
};
} }
next(); next();
@@ -150,6 +147,7 @@ export default async function initServer() {
router.use(alertsRouter); router.use(alertsRouter);
router.use(scenesRouter); router.use(scenesRouter);
router.use(actorsRouter); router.use(actorsRouter);
router.use(syncRouter);
// MOVIES // MOVIES
router.get('/api/movies', fetchMoviesApi); router.get('/api/movies', fetchMoviesApi);

49
src/web/sync.js Normal file
View File

@@ -0,0 +1,49 @@
import Router from 'express-promise-router';
import {
syncScenes,
syncMovies,
syncActors,
syncStashes,
} from '../sync.js';
import verifyAbility from '../../utils/verify-ability.js';
export const syncRouter = Router();
async function syncScenesApi(req, res) {
verifyAbility(req.user, 'sync', null, { throwError: true });
await syncScenes(req.body.sceneIds);
res.status(204).send();
}
async function syncMoviesApi(req, res) {
verifyAbility(req.user, 'sync', null, { throwError: true });
await syncMovies(req.body.movieIds);
res.status(204).send();
}
async function syncActorsApi(req, res) {
verifyAbility(req.user, 'sync', null, { throwError: true });
await syncActors(req.body.actorIds);
res.status(204).send();
}
async function syncStashesApi(req, res) {
verifyAbility(req.user, 'sync', null, { throwError: true });
await syncStashes(req.body.stashIds);
res.status(204).send();
}
syncRouter.post('/api/sync/scenes', syncScenesApi);
syncRouter.post('/api/sync/movies', syncMoviesApi);
syncRouter.post('/api/sync/actors', syncActorsApi);
syncRouter.post('/api/sync/stashes', syncStashesApi);

2
static

Submodule static updated: 217845ef37...d77e9faeb9

43
tools/manticore-actors.js Normal file
View File

@@ -0,0 +1,43 @@
import argv from '../src/argv.js';
import { knexOwner as knex } from '../src/knex.js';
import { utilsApi } from '../src/manticore.js';
import { syncManticoreActors } from '../src/sync.js';
async function init() {
if (argv.update) {
await utilsApi.sql('drop table if exists actors');
await utilsApi.sql(`create table actors(
id int,
name text,
slug string,
entity_id int,
gender string,
date_of_birth timestamp,
country string,
has_avatar bool,
mass int,
height int,
cup string,
natural_boobs int,
penis_length int,
penis_girth int,
stashed int,
scenes int
) min_prefix_len = '3'`);
console.log('Recreated actors table, syncing actors...');
const data = await syncManticoreActors();
console.log('data', data);
knex.destroy();
return;
}
knex.destroy();
}
init();

45
tools/manticore-movies.js Normal file
View File

@@ -0,0 +1,45 @@
import argv from '../src/argv.js';
import { knexOwner as knex } from '../src/knex.js';
import { utilsApi } from '../src/manticore.js';
import { syncManticoreMovies } from '../src/sync.js';
async function init() {
if (argv.update) {
await utilsApi.sql('drop table if exists movies');
await utilsApi.sql(`create table movies (
id int,
title text,
title_filtered text,
channel_id int,
channel_name text,
channel_slug text,
network_id int,
network_name text,
network_slug text,
entity_ids multi,
actor_ids multi,
actors text,
tag_ids multi,
tags text,
meta text,
date timestamp,
has_cover bool,
created_at timestamp,
effective_date timestamp,
stashed int,
stashed_scenes int,
stashed_total int,
dupe_index int
)`);
console.log('Recreated movies tables, syncing movies...');
const data = await syncManticoreMovies();
console.log('data', data);
}
knex.destroy();
}
init();

59
tools/manticore-scenes.js Normal file
View File

@@ -0,0 +1,59 @@
import argv from '../src/argv.js';
import { knexOwner as knex } from '../src/knex.js';
import { utilsApi } from '../src/manticore.js';
import { syncManticoreScenes } from '../src/sync.js';
async function init() {
if (argv.update) {
await utilsApi.sql('drop table if exists scenes');
await utilsApi.sql(`create table scenes (
id int,
title text,
title_filtered text,
entry_id text,
shoot_id text,
channel_id int,
channel_name text,
channel_slug text,
network_id int,
network_name text,
network_slug text,
studio_id int,
studio_name text,
studio_slug text,
entity_ids multi,
actor_ids multi,
actors text,
tag_ids multi,
tags text,
movie_ids multi,
movies text,
serie_ids multi,
series text,
meta text,
date timestamp,
fingerprints text,
is_showcased bool,
created_at timestamp,
effective_date timestamp,
stashed int,
dupe_index int
)`);
await utilsApi.sql('drop table if exists scenes_tags');
await utilsApi.sql(`create table scenes_tags (
id int,
scene_id int,
tag_id int,
actor_id int
)`);
console.log('Recreated scenes tables, syncing scenes...');
await syncManticoreScenes();
}
knex.destroy();
}
init();

View File

@@ -0,0 +1,42 @@
import { knexOwner as knex } from '../src/knex.js';
import { utilsApi } from '../src/manticore.js';
import { syncStashes } from '../src/sync.js';
async function init() {
await utilsApi.sql('drop table if exists scenes_stashed');
await utilsApi.sql(`create table if not exists scenes_stashed (
scene_id int,
stash_id int,
user_id int,
created_at timestamp
)`);
await utilsApi.sql('drop table if exists movies_stashed');
await utilsApi.sql(`create table if not exists movies_stashed (
movie_id int,
stash_id int,
user_id int,
created_at timestamp
)`);
await utilsApi.sql('drop table if exists actors_stashed');
await utilsApi.sql(`create table if not exists actors_stashed (
actor_id int,
stash_id int,
user_id int,
created_at timestamp
)`);
console.log('Recreated stash tables, syncing stashes...');
await syncStashes('scene');
await syncStashes('actor');
await syncStashes('movie');
knex.destroy();
}
init();

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();

7
utils/filter-title.js Normal file
View File

@@ -0,0 +1,7 @@
export default function filterTitle(title, keys) {
if (!title) {
return title;
}
return keys.reduce((accTitle, tag) => accTitle.replace(new RegExp(`\\b${tag.replace(/[^\w\s]+/g, '')}\\b`, 'gi'), ''), title).trim().replace(/\s{2,}/, ' ');
}

27
utils/verify-ability.js Normal file
View File

@@ -0,0 +1,27 @@
import { HttpError } from '../src/errors.js';
function checkAbility(user, subject, action) {
if (!user?.abilities) {
return null;
}
if (subject && action) {
return user.abilities.some((ability) => ability.subject === subject && ability.action === action);
}
if (subject) {
return user.abilities.some((ability) => ability[subject] === true || (ability.subject === subject && !ability.action));
}
return false;
}
export default function verifyAbility(user, subject, action, options = {}) {
const isAble = checkAbility(user, subject, action);
if (!isAble && options.throwError) {
throw new HttpError(`Insufficient privileges for ${[subject, action].filter(Boolean).join()}`, 403);
}
return isAble;
}