Added unidentified actors and actor count filters.
This commit is contained in:
@@ -69,6 +69,7 @@
|
|||||||
|
|
||||||
--male: #0af;
|
--male: #0af;
|
||||||
--female: #f0a;
|
--female: #f0a;
|
||||||
|
--trans: #a0f;
|
||||||
|
|
||||||
--enabled: #5c2;
|
--enabled: #5c2;
|
||||||
--enabled-background: rgba(0, 255, 0, .1);
|
--enabled-background: rgba(0, 255, 0, .1);
|
||||||
|
|||||||
@@ -16,6 +16,18 @@ import Pagination from '#/components/pagination/pagination.vue';
|
|||||||
import { get } from '#/src/api.js';
|
import { get } from '#/src/api.js';
|
||||||
import events from '#/src/events.js';
|
import events from '#/src/events.js';
|
||||||
import navigate from '#/src/navigate.js';
|
import navigate from '#/src/navigate.js';
|
||||||
|
import { formatRange, parseLetterRange, parseRange } from '#/utils/parse-range.js';
|
||||||
|
|
||||||
|
// sliders need both ends, fill open ends with the slider limits
|
||||||
|
function getSliderRange(rangeString, [defaultMin, defaultMax], parse = parseRange) {
|
||||||
|
const range = parse(rangeString);
|
||||||
|
|
||||||
|
return [range?.min ?? defaultMin, range?.max ?? defaultMax];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSliderRange([min, max]) {
|
||||||
|
return formatRange({ min, max });
|
||||||
|
}
|
||||||
|
|
||||||
const pageContext = inject('pageContext');
|
const pageContext = inject('pageContext');
|
||||||
const { pageProps, urlParsed, routeParams } = pageContext;
|
const { pageProps, urlParsed, routeParams } = pageContext;
|
||||||
@@ -35,18 +47,18 @@ const order = ref(routeParams.order || urlParsed.search.order || 'likes.desc');
|
|||||||
const filters = ref({
|
const filters = ref({
|
||||||
gender: urlParsed.search.gender,
|
gender: urlParsed.search.gender,
|
||||||
ageRequired: !!urlParsed.search.age,
|
ageRequired: !!urlParsed.search.age,
|
||||||
age: urlParsed.search.age?.split(',').map((age) => Number(age)) || [18, 100],
|
age: getSliderRange(urlParsed.search.age, [18, 100]),
|
||||||
dobRequired: !!urlParsed.search.dob,
|
dobRequired: !!urlParsed.search.dob,
|
||||||
dobType: urlParsed.search.dobt ? ({ bd: 'birthday', dob: 'dob' })[urlParsed.search.dobt] : 'birthday',
|
dobType: urlParsed.search.dobt ? ({ bd: 'birthday', dob: 'dob' })[urlParsed.search.dobt] : 'birthday',
|
||||||
dob: urlParsed.search.dob || format(subYears(new Date(), 21), 'yyyy-MM-dd'),
|
dob: urlParsed.search.dob || format(subYears(new Date(), 21), 'yyyy-MM-dd'),
|
||||||
country: urlParsed.search.c,
|
country: urlParsed.search.c,
|
||||||
braSizeRequired: !!urlParsed.search.cup,
|
braSizeRequired: !!urlParsed.search.cup,
|
||||||
braSize: urlParsed.search.cup?.split(',') || ['A', 'Z'],
|
braSize: getSliderRange(urlParsed.search.cup, ['A', 'Z'], parseLetterRange),
|
||||||
naturalBoobs: urlParsed.search.nb ? urlParsed.search.nb === 'true' : undefined,
|
naturalBoobs: urlParsed.search.nb ? urlParsed.search.nb === 'true' : undefined,
|
||||||
heightRequired: !!urlParsed.search.height,
|
heightRequired: !!urlParsed.search.height,
|
||||||
height: urlParsed.search.height?.split(',').map((height) => Number(height)) || [50, 220],
|
height: getSliderRange(urlParsed.search.height, [50, 220]),
|
||||||
weightRequired: !!urlParsed.search.weight,
|
weightRequired: !!urlParsed.search.weight,
|
||||||
weight: urlParsed.search.weight?.split(',').map((weight) => Number(weight)) || [30, 200],
|
weight: getSliderRange(urlParsed.search.weight, [30, 200]),
|
||||||
avatarRequired: !!urlParsed.search.avatar,
|
avatarRequired: !!urlParsed.search.avatar,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,14 +97,14 @@ async function search(options = {}) {
|
|||||||
q: q.value || undefined,
|
q: q.value || undefined,
|
||||||
order: order.value,
|
order: order.value,
|
||||||
gender: filters.value.gender || undefined,
|
gender: filters.value.gender || undefined,
|
||||||
age: filters.value.ageRequired ? filters.value.age.join(',') : undefined,
|
age: filters.value.ageRequired ? formatSliderRange(filters.value.age) : undefined,
|
||||||
dob: filters.value.dobRequired ? filters.value.dob : undefined,
|
dob: filters.value.dobRequired ? filters.value.dob : undefined,
|
||||||
dobt: filters.value.dobRequired ? ({ birthday: 'bd', dob: 'dob' })[filters.value.dobType] : undefined,
|
dobt: filters.value.dobRequired ? ({ birthday: 'bd', dob: 'dob' })[filters.value.dobType] : undefined,
|
||||||
cup: filters.value.braSizeRequired ? filters.value.braSize.join(',') : undefined,
|
cup: filters.value.braSizeRequired ? formatSliderRange(filters.value.braSize) : undefined,
|
||||||
c: filters.value.country || undefined,
|
c: filters.value.country || undefined,
|
||||||
nb: filters.value.naturalBoobs,
|
nb: filters.value.naturalBoobs,
|
||||||
height: filters.value.heightRequired ? filters.value.height.join(',') : undefined,
|
height: filters.value.heightRequired ? formatSliderRange(filters.value.height) : undefined,
|
||||||
weight: filters.value.weightRequired ? filters.value.weight.join(',') : undefined,
|
weight: filters.value.weightRequired ? formatSliderRange(filters.value.weight) : undefined,
|
||||||
avatar: filters.value.avatarRequired || undefined,
|
avatar: filters.value.avatarRequired || undefined,
|
||||||
stashId: pageStash?.id,
|
stashId: pageStash?.id,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
import { ref, useId } from 'vue';
|
import { ref, useId } from 'vue';
|
||||||
|
|
||||||
import { get } from '#/src/api.js';
|
import { get } from '#/src/api.js';
|
||||||
@@ -9,7 +10,12 @@ defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
showUnknown: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['actor']);
|
const emit = defineEmits(['actor']);
|
||||||
const dropdownId = useId();
|
const dropdownId = useId();
|
||||||
const actors = ref([]);
|
const actors = ref([]);
|
||||||
@@ -49,6 +55,23 @@ function focus() {
|
|||||||
>
|
>
|
||||||
|
|
||||||
<ul class="actors nolist">
|
<ul class="actors nolist">
|
||||||
|
<li
|
||||||
|
v-if="showUnknown"
|
||||||
|
class="actor-unknown"
|
||||||
|
>
|
||||||
|
<span class="actor-label">Unidentified</span>
|
||||||
|
|
||||||
|
<Icon
|
||||||
|
v-for="gender in ['male', 'female', 'transsexual', null]"
|
||||||
|
:key="`actor-unknown-${gender}`"
|
||||||
|
v-close-popper
|
||||||
|
:icon="gender || 'question5'"
|
||||||
|
class="actor-gender"
|
||||||
|
:class="{ [`actor-${gender || 'other'}`]: true }"
|
||||||
|
@click="emit('actor', { name: gender ? `Unknown ${gender}` : 'Unknown', gender, isUnknown: true, id: `${gender || 'other'}_${nanoid()}` })"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
|
||||||
<li
|
<li
|
||||||
v-for="actor in actors"
|
v-for="actor in actors"
|
||||||
:key="`actor-${actor.slug}`"
|
:key="`actor-${actor.slug}`"
|
||||||
@@ -89,6 +112,41 @@ function focus() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.actor-unknown {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.actor-label {
|
||||||
|
padding: .25rem .5rem;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actor-gender {
|
||||||
|
padding: .5rem .5rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--glass-weak-50);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actor-male {
|
||||||
|
fill: var(--male);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actor-female {
|
||||||
|
fill: var(--female);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actor-transsexual {
|
||||||
|
fill: var(--trans);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actor-other {
|
||||||
|
fill: var(--glass);
|
||||||
|
}
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const aliasTooltipId = useId();
|
|||||||
>
|
>
|
||||||
<div class="avatar-container">
|
<div class="avatar-container">
|
||||||
<Link
|
<Link
|
||||||
:href="`/actor/${actor.id}/${actor.slug}`"
|
:href="actor.isUnknown ? null : `/actor/${actor.id}/${actor.slug}`"
|
||||||
class="avatar-link no-link"
|
class="avatar-link no-link"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@@ -101,10 +101,16 @@ const aliasTooltipId = useId();
|
|||||||
|
|
||||||
<span class="label">
|
<span class="label">
|
||||||
<span
|
<span
|
||||||
|
v-if="actor.name"
|
||||||
class="name ellipsis"
|
class="name ellipsis"
|
||||||
:style="{ 'font-size': `${Math.max(0.9 + Math.min((17 - actor.name.length), 0) * 0.06, 0.65)}rem` }"
|
:style="{ 'font-size': `${Math.max(0.9 + Math.min((17 - actor.name.length), 0) * 0.06, 0.65)}rem` }"
|
||||||
>{{ actor.name }}</span>
|
>{{ actor.name }}</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="name unknown"
|
||||||
|
>Unknown {{ actor.gender }}</span>
|
||||||
|
|
||||||
<img
|
<img
|
||||||
v-if="actor.entity"
|
v-if="actor.entity"
|
||||||
v-tooltip="{ content: actor.entity.name, ariaId: entityTooltipId }"
|
v-tooltip="{ content: actor.entity.name, ariaId: entityTooltipId }"
|
||||||
@@ -179,6 +185,10 @@ const aliasTooltipId = useId();
|
|||||||
|
|
||||||
.name {
|
.name {
|
||||||
padding: 0 .25rem 0 .5rem;
|
padding: 0 .25rem 0 .5rem;
|
||||||
|
|
||||||
|
&.unknown {
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.favicon {
|
.favicon {
|
||||||
|
|||||||
@@ -46,9 +46,12 @@ watch(() => props.scene, () => {
|
|||||||
v-for="actor in [...item.value, ...newActors]"
|
v-for="actor in [...item.value, ...newActors]"
|
||||||
:key="`actor-${actor.id}`"
|
:key="`actor-${actor.id}`"
|
||||||
class="actor"
|
class="actor"
|
||||||
:class="{ deleted: edits.actors && !edits.actors.some((actorId) => actorId === actor.id) }"
|
:class="{
|
||||||
|
deleted: edits.actors && !edits.actors.some((actorId) => actorId === actor.id),
|
||||||
|
unknown: actor.isUnknown,
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<span class="actor-name">{{ actor.name }}</span>
|
<span class="actor-name">{{ actor.name || ['Unknown', actor.gender].filter(Boolean).join(' ') }}</span>
|
||||||
|
|
||||||
<Icon
|
<Icon
|
||||||
v-if="edits.actors && !edits.actors.some((actorId) => actorId === actor.id)"
|
v-if="edits.actors && !edits.actors.some((actorId) => actorId === actor.id)"
|
||||||
@@ -68,6 +71,7 @@ watch(() => props.scene, () => {
|
|||||||
<li class="new">
|
<li class="new">
|
||||||
<ActorSearch
|
<ActorSearch
|
||||||
:disabled="!editing.has(item.key)"
|
:disabled="!editing.has(item.key)"
|
||||||
|
:show-unknown="true"
|
||||||
@actor="addActor"
|
@actor="addActor"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
@@ -131,6 +135,10 @@ watch(() => props.scene, () => {
|
|||||||
color: var(--glass);
|
color: var(--glass);
|
||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.unknown {
|
||||||
|
color: var(--glass-strong-10);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.actor,
|
.actor,
|
||||||
|
|||||||
@@ -41,6 +41,17 @@ const total = ref(pageContext.pageProps.revisionTotal);
|
|||||||
const page = Number(pageContext.routeParams.page) || 1;
|
const page = Number(pageContext.routeParams.page) || 1;
|
||||||
const limit = Number(pageContext.urlParsed.search.limit) || 100;
|
const limit = Number(pageContext.urlParsed.search.limit) || 100;
|
||||||
|
|
||||||
|
function getUnknownName(itemId) {
|
||||||
|
// unidentified actors are stored as gender_id
|
||||||
|
if (typeof itemId !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gender = itemId.match(/^(male|female|transsexual)_/)?.[1];
|
||||||
|
|
||||||
|
return ['Unknown', gender].filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
const mappedKeys = {
|
const mappedKeys = {
|
||||||
actors: actorsById,
|
actors: actorsById,
|
||||||
// tags: tagsById,
|
// tags: tagsById,
|
||||||
@@ -64,7 +75,7 @@ const curatedRevisions = computed(() => revisions.value.map((revision) => {
|
|||||||
if (Array.isArray(value) && mappedKeys[key]) {
|
if (Array.isArray(value) && mappedKeys[key]) {
|
||||||
return [key, value.map((itemId) => ({
|
return [key, value.map((itemId) => ({
|
||||||
id: itemId,
|
id: itemId,
|
||||||
name: mappedKeys[key].value[itemId]?.name || mappedKeys[key].value[itemId]?.title,
|
name: mappedKeys[key].value[itemId]?.name || mappedKeys[key].value[itemId]?.title || getUnknownName(itemId),
|
||||||
modified: revision.deltas.some((delta) => delta.key === key && !delta.value.some((deltaItemId) => deltaItemId === itemId)),
|
modified: revision.deltas.some((delta) => delta.key === key && !delta.value.some((deltaItemId) => deltaItemId === itemId)),
|
||||||
}))];
|
}))];
|
||||||
}
|
}
|
||||||
@@ -104,7 +115,7 @@ const curatedRevisions = computed(() => revisions.value.map((revision) => {
|
|||||||
...delta,
|
...delta,
|
||||||
value: delta.value.map((itemId) => ({
|
value: delta.value.map((itemId) => ({
|
||||||
id: itemId,
|
id: itemId,
|
||||||
name: mappedKeys[delta.key].value[itemId]?.name || mappedKeys[delta.key].value[itemId]?.title,
|
name: mappedKeys[delta.key].value[itemId]?.name || mappedKeys[delta.key].value[itemId]?.title || getUnknownName(itemId),
|
||||||
modified: !revision.base[delta.key].includes(itemId),
|
modified: !revision.base[delta.key].includes(itemId),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ watch(() => props.scene, () => {
|
|||||||
.tags-sections {
|
.tags-sections {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: .75rem;
|
gap: 1.5rem;
|
||||||
margin: .5rem 0;
|
margin: .5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
ref,
|
||||||
|
} from 'vue';
|
||||||
|
|
||||||
|
import Range from '#/components/form/range.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
filters: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
counts: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update']);
|
||||||
|
|
||||||
|
// beyond this the slider loses too much fidelity for the common small counts, the last step means 'or more'
|
||||||
|
const sliderCap = 10;
|
||||||
|
|
||||||
|
const rows = [
|
||||||
|
{ key: 'group', label: 'Actors', icon: 'users' },
|
||||||
|
{ key: 'females', label: 'Females', icon: 'female' },
|
||||||
|
{ key: 'males', label: 'Males', icon: 'male' },
|
||||||
|
{ key: 'trans', label: 'Trans', icon: 'transsexual' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// values while dragging, before the change is committed and the scenes reload
|
||||||
|
const dragging = ref({});
|
||||||
|
|
||||||
|
const sliders = computed(() => rows.map((row) => {
|
||||||
|
const filter = props.filters[row.key];
|
||||||
|
const buckets = props.counts?.[row.key] || [];
|
||||||
|
const countMax = buckets.at(-1)?.value ?? sliderCap; // buckets are sorted by value
|
||||||
|
// at least 1 to keep the slider usable, and never below the active filter so its handles stay in view
|
||||||
|
const max = Math.max(1, Math.min(countMax, sliderCap), filter?.min ?? 0, filter?.max ?? 0);
|
||||||
|
const value = [filter?.min ?? 0, filter?.max ?? max];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
max,
|
||||||
|
isCapped: countMax > max,
|
||||||
|
value,
|
||||||
|
display: dragging.value[row.key] || value,
|
||||||
|
mountKey: `${row.key}-${max}-${value.join('-')}`, // remount when the range or filter changes, the slider only reads its value on mount
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
|
||||||
|
function getEndLabel(slider, value) {
|
||||||
|
return value === slider.max && slider.isCapped ? `${value}+` : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRange(slider, [min, max], reload) {
|
||||||
|
if (!reload) {
|
||||||
|
dragging.value[slider.key] = [min, max];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete dragging.value[slider.key];
|
||||||
|
|
||||||
|
// the top of the slider means 'or more', the full range means no filter
|
||||||
|
const range = {
|
||||||
|
min,
|
||||||
|
max: max === slider.max ? null : max,
|
||||||
|
};
|
||||||
|
|
||||||
|
emit('update', slider.key, min === 0 && range.max === null ? null : range, true);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="filter groups-container">
|
||||||
|
<div
|
||||||
|
v-for="slider in sliders"
|
||||||
|
:key="slider.key"
|
||||||
|
class="filter-row"
|
||||||
|
:title="slider.label"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
:icon="slider.icon"
|
||||||
|
:class="`gender-${slider.key}`"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Range
|
||||||
|
:key="slider.mountKey"
|
||||||
|
:min="0"
|
||||||
|
:max="slider.max"
|
||||||
|
:value="slider.value"
|
||||||
|
:allow-enable="false"
|
||||||
|
@input="(range) => updateRange(slider, range, false)"
|
||||||
|
@change="(range) => updateRange(slider, range, true)"
|
||||||
|
>
|
||||||
|
<template #start><span class="range-label">{{ slider.display[0] }}</span></template>
|
||||||
|
<template #end><span class="range-label">{{ getEndLabel(slider, slider.display[1]) }}</span></template>
|
||||||
|
</Range>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.groups-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0 .25rem;
|
||||||
|
|
||||||
|
.range-container {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-label {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 1.5rem;
|
||||||
|
color: var(--glass-strong-10);
|
||||||
|
font-size: .9rem;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-container {
|
||||||
|
padding: .25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
fill: var(--glass);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-females {
|
||||||
|
fill: var(--female);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-males {
|
||||||
|
fill: var(--male);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-trans {
|
||||||
|
fill: var(--trans);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -12,6 +12,7 @@ import Campaign from '#/components/campaigns/campaign.vue';
|
|||||||
import ActorsFilter from '#/components/filters/actors.vue';
|
import ActorsFilter from '#/components/filters/actors.vue';
|
||||||
import ChannelsFilter from '#/components/filters/channels.vue';
|
import ChannelsFilter from '#/components/filters/channels.vue';
|
||||||
import Filters from '#/components/filters/filters.vue';
|
import Filters from '#/components/filters/filters.vue';
|
||||||
|
import GroupsFilter from '#/components/filters/groups.vue';
|
||||||
|
|
||||||
import TagsFilter from '#/components/filters/tags.vue';
|
import TagsFilter from '#/components/filters/tags.vue';
|
||||||
import YearsFilter from '#/components/filters/years.vue';
|
import YearsFilter from '#/components/filters/years.vue';
|
||||||
@@ -23,6 +24,7 @@ import { get } from '#/src/api.js';
|
|||||||
import entityPrefixes from '#/src/entities-prefixes.js';
|
import entityPrefixes from '#/src/entities-prefixes.js';
|
||||||
import navigate from '#/src/navigate.js';
|
import navigate from '#/src/navigate.js';
|
||||||
import { getActorIdentifier, parseActorIdentifier } from '#/src/query.js';
|
import { getActorIdentifier, parseActorIdentifier } from '#/src/query.js';
|
||||||
|
import { formatRange, parseRange } from '#/utils/parse-range.js';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
showFilters: {
|
showFilters: {
|
||||||
@@ -64,6 +66,7 @@ const aggActors = ref(pageProps.aggActors || []);
|
|||||||
const aggTags = ref(pageProps.aggTags || []);
|
const aggTags = ref(pageProps.aggTags || []);
|
||||||
const aggActorTags = ref(pageProps.aggActorTags || []);
|
const aggActorTags = ref(pageProps.aggActorTags || []);
|
||||||
const aggChannels = ref(pageProps.aggChannels || []);
|
const aggChannels = ref(pageProps.aggChannels || []);
|
||||||
|
const aggActorCounts = ref(pageProps.aggActorCounts || null);
|
||||||
|
|
||||||
const currentPage = ref(Number(routeParams.page));
|
const currentPage = ref(Number(routeParams.page));
|
||||||
const scope = ref(routeParams.scope || props.defaultScope);
|
const scope = ref(routeParams.scope || props.defaultScope);
|
||||||
@@ -88,6 +91,10 @@ const filters = ref({
|
|||||||
onlyActorTags: Object.hasOwn(urlParsed.search, 'at'),
|
onlyActorTags: Object.hasOwn(urlParsed.search, 'at'),
|
||||||
entity: queryEntity,
|
entity: queryEntity,
|
||||||
actors: queryActors,
|
actors: queryActors,
|
||||||
|
group: parseRange(urlParsed.search.group),
|
||||||
|
females: parseRange(urlParsed.search.females),
|
||||||
|
males: parseRange(urlParsed.search.males),
|
||||||
|
trans: parseRange(urlParsed.search.trans),
|
||||||
});
|
});
|
||||||
|
|
||||||
const sceneCampaign = campaigns?.scenes;
|
const sceneCampaign = campaigns?.scenes;
|
||||||
@@ -132,6 +139,10 @@ async function search(options = {}) {
|
|||||||
|
|
||||||
const query = {
|
const query = {
|
||||||
q: filters.value.search || undefined,
|
q: filters.value.search || undefined,
|
||||||
|
group: formatRange(filters.value.group),
|
||||||
|
females: formatRange(filters.value.females),
|
||||||
|
males: formatRange(filters.value.males),
|
||||||
|
trans: formatRange(filters.value.trans),
|
||||||
};
|
};
|
||||||
|
|
||||||
const entity = filters.value.entity || pageEntity;
|
const entity = filters.value.entity || pageEntity;
|
||||||
@@ -168,6 +179,7 @@ async function search(options = {}) {
|
|||||||
aggTags.value = res.aggTags;
|
aggTags.value = res.aggTags;
|
||||||
aggActorTags.value = res.aggActorTags;
|
aggActorTags.value = res.aggActorTags;
|
||||||
aggChannels.value = res.aggChannels;
|
aggChannels.value = res.aggChannels;
|
||||||
|
aggActorCounts.value = res.aggActorCounts;
|
||||||
|
|
||||||
total.value = res.total;
|
total.value = res.total;
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
@@ -248,6 +260,12 @@ function setView(newView) {
|
|||||||
:actors="aggActors"
|
:actors="aggActors"
|
||||||
@update="updateFilter"
|
@update="updateFilter"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<GroupsFilter
|
||||||
|
:filters="filters"
|
||||||
|
:counts="aggActorCounts"
|
||||||
|
@update="updateFilter"
|
||||||
|
/>
|
||||||
</Filters>
|
</Filters>
|
||||||
</transition>
|
</transition>
|
||||||
|
|
||||||
|
|||||||
@@ -92,16 +92,23 @@ const favorited = ref(props.scene.stashes.some((sceneStash) => sceneStash.id ===
|
|||||||
|
|
||||||
<ul
|
<ul
|
||||||
class="row actors nolist"
|
class="row actors nolist"
|
||||||
:title="scene.actors.map((actor) => actor.name).join(', ')"
|
:title="scene.actors.map((actor) => actor.name || 'Unknown').join(', ')"
|
||||||
>
|
>
|
||||||
<li
|
<li
|
||||||
v-for="actor in scene.actors"
|
v-for="actor in scene.actors"
|
||||||
:key="`actor-${scene.id}-${actor.id}`"
|
:key="`actor-${scene.id}-${actor.id}`"
|
||||||
class="actor"
|
class="actor"
|
||||||
|
:class="{ unknown: actor.isUnknown }"
|
||||||
>
|
>
|
||||||
|
<span
|
||||||
|
v-if="actor.isUnknown"
|
||||||
|
class="actor-name"
|
||||||
|
>{{ ['Unknown', actor.gender].filter(Boolean).join(' ') }}</span>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
|
v-else
|
||||||
:href="`/actor/${actor.id}/${actor.slug}`"
|
:href="`/actor/${actor.id}/${actor.slug}`"
|
||||||
class="nolink"
|
class="actor-name nolink"
|
||||||
>{{ actor.name }}</Link>
|
>{{ actor.name }}</Link>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -237,7 +244,7 @@ const favorited = ref(props.scene.stashes.some((sceneStash) => sceneStash.id ===
|
|||||||
color: var(--shadow);
|
color: var(--shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.actor:hover,
|
.actor:not(.unknown):hover,
|
||||||
.tag:hover {
|
.tag:hover {
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
@@ -255,6 +262,13 @@ const favorited = ref(props.scene.stashes.some((sceneStash) => sceneStash.id ===
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.actor.unknown {
|
||||||
|
.actor-name,
|
||||||
|
&:not(:last-child)::after {
|
||||||
|
color: var(--glass);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.tags {
|
.tags {
|
||||||
height: 1.25rem;
|
height: 1.25rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -786,7 +786,7 @@ async function submit() {
|
|||||||
.row {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: .25rem 1rem;
|
padding: .75rem 1rem;
|
||||||
margin-bottom: .25rem;
|
margin-bottom: .25rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
@@ -797,10 +797,14 @@ async function submit() {
|
|||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
border-bottom: solid 1px var(--glass-weak-40);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.key {
|
.key {
|
||||||
width: 10rem;
|
width: 9rem;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
@@ -945,7 +949,7 @@ async function submit() {
|
|||||||
|
|
||||||
.item-actions {
|
.item-actions {
|
||||||
.icon {
|
.icon {
|
||||||
padding: .25rem 1rem;
|
padding: .25rem 1.5rem .25rem 1rem;
|
||||||
fill: var(--glass);
|
fill: var(--glass);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
|
|||||||
@@ -451,7 +451,11 @@ async function submit() {
|
|||||||
.row {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: .25rem 1rem;
|
padding: .75rem 1rem;
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
border-bottom: solid 1px var(--glass-weak-40);
|
||||||
|
}
|
||||||
|
|
||||||
.value.disabled {
|
.value.disabled {
|
||||||
color: var(--glass);
|
color: var(--glass);
|
||||||
@@ -512,7 +516,7 @@ async function submit() {
|
|||||||
|
|
||||||
.item-actions {
|
.item-actions {
|
||||||
.icon {
|
.icon {
|
||||||
padding: .25rem 1rem;
|
padding: .25rem 1.5rem .25rem 1rem;
|
||||||
fill: var(--glass);
|
fill: var(--glass);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export async function onBeforeRender(pageContext) {
|
|||||||
aggActors,
|
aggActors,
|
||||||
aggTags,
|
aggTags,
|
||||||
aggChannels,
|
aggChannels,
|
||||||
|
aggActorCounts,
|
||||||
total: sceneTotal,
|
total: sceneTotal,
|
||||||
} = searchScenes;
|
} = searchScenes;
|
||||||
|
|
||||||
@@ -68,6 +69,7 @@ export async function onBeforeRender(pageContext) {
|
|||||||
aggActors,
|
aggActors,
|
||||||
aggTags,
|
aggTags,
|
||||||
aggChannels,
|
aggChannels,
|
||||||
|
aggActorCounts,
|
||||||
sceneTotal,
|
sceneTotal,
|
||||||
actorTotal,
|
actorTotal,
|
||||||
movieTotal,
|
movieTotal,
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ const tagSlugs = {
|
|||||||
'free-use',
|
'free-use',
|
||||||
'latex',
|
'latex',
|
||||||
'blindfold',
|
'blindfold',
|
||||||
|
'body-writing',
|
||||||
],
|
],
|
||||||
manual: [
|
manual: [
|
||||||
'handjob',
|
'handjob',
|
||||||
|
|||||||
+62
-23
@@ -24,7 +24,7 @@ import { curateMedia } from './media.js';
|
|||||||
import redis from './redis.js';
|
import redis from './redis.js';
|
||||||
import { curateRevision } from './revisions.js';
|
import { curateRevision } from './revisions.js';
|
||||||
import { curateStash } from './stashes.js';
|
import { curateStash } from './stashes.js';
|
||||||
import { syncActors, syncScenes, syncStashes } from './sync.js';
|
import { syncActors, syncManticoreScenes, syncScenes, syncStashes } from './sync.js';
|
||||||
|
|
||||||
const logger = initLogger();
|
const logger = initLogger();
|
||||||
const mj = new MerkleJson();
|
const mj = new MerkleJson();
|
||||||
@@ -235,15 +235,17 @@ export function sortActorsByGender(actors, context = {}) {
|
|||||||
return actors;
|
return actors;
|
||||||
}
|
}
|
||||||
|
|
||||||
const alphaActors = actors.sort((actorA, actorB) => actorA.name.localeCompare(actorB.name, 'en'));
|
const alphaActors = actors.toSorted((actorA, actorB) => actorA.name?.localeCompare(actorB.name, 'en'));
|
||||||
const genderActors = ['transsexual', 'female', undefined, null, 'male'].flatMap((gender) => alphaActors.filter((actor) => actor.gender === gender));
|
|
||||||
|
|
||||||
const titleSlug = slugify(context.title);
|
const titleSlug = slugify(context.title);
|
||||||
|
|
||||||
const titleActors = titleSlug
|
const titleActors = titleSlug
|
||||||
? genderActors.sort((actorA, actorB) => {
|
? alphaActors.sort((actorA, actorB) => {
|
||||||
const actorASlug = actorA.slug.split('-')[0];
|
const actorASlug = actorA.slug?.split('-')[0];
|
||||||
const actorBSlug = actorB.slug.split('-')[0];
|
const actorBSlug = actorB.slug?.split('-')[0];
|
||||||
|
|
||||||
|
if (!actorASlug || !actorBSlug) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
if (titleSlug.includes(actorASlug) && !titleSlug.includes(actorBSlug)) {
|
if (titleSlug.includes(actorASlug) && !titleSlug.includes(actorBSlug)) {
|
||||||
return -1;
|
return -1;
|
||||||
@@ -257,7 +259,10 @@ export function sortActorsByGender(actors, context = {}) {
|
|||||||
})
|
})
|
||||||
: alphaActors;
|
: alphaActors;
|
||||||
|
|
||||||
return titleActors;
|
const genderActors = ['transsexual', 'female', undefined, null, 'male'].flatMap((gender) => titleActors.filter((actor) => actor.gender === gender));
|
||||||
|
const identifiedActors = genderActors.toSorted((actorA, actorB) => !!actorA.isUnknown - !!actorB.isUnknown);
|
||||||
|
|
||||||
|
return identifiedActors;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchActorsById(actorIds, options = {}, reqUser) {
|
export async function fetchActorsById(actorIds, options = {}, reqUser) {
|
||||||
@@ -465,22 +470,21 @@ async function queryManticoreSql(filters, options, _reqUser) {
|
|||||||
builder.select('if(date_of_birth, floor((now() - date_of_birth) / 31556952), 0) as age');
|
builder.select('if(date_of_birth, floor((now() - date_of_birth) / 31556952), 0) as age');
|
||||||
}
|
}
|
||||||
|
|
||||||
// range filters
|
// range filters, either end may be open, weight is a reserved keyword in manticore
|
||||||
['age', 'height'].forEach((attribute) => {
|
Object.entries({ age: 'age', height: 'height', weight: 'mass' }).forEach(([attribute, column]) => {
|
||||||
if (filters[attribute]) {
|
if (typeof filters[attribute]?.min === 'number') {
|
||||||
builder
|
builder.where(column, '>=', filters[attribute].min);
|
||||||
.where(attribute, '>=', filters[attribute][0])
|
}
|
||||||
.where(attribute, '<=', filters[attribute][1]);
|
|
||||||
|
if (typeof filters[attribute]?.max === 'number') {
|
||||||
|
builder.where(column, '<=', filters[attribute].max);
|
||||||
|
|
||||||
|
if (typeof filters[attribute].min !== 'number') {
|
||||||
|
builder.where(column, '>', 0); // unknown values are stored as 0, an open lower bound should not match them
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (filters.weight) {
|
|
||||||
// weight is a reserved keyword in manticore
|
|
||||||
builder
|
|
||||||
.where('mass', '>=', filters.weight[0])
|
|
||||||
.where('mass', '<=', filters.weight[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.dateOfBirth && filters.dobType === 'dateOfBirth') {
|
if (filters.dateOfBirth && filters.dobType === 'dateOfBirth') {
|
||||||
builder.where('date_of_birth', Math.floor(filters.dateOfBirth.getTime() / 1000));
|
builder.where('date_of_birth', Math.floor(filters.dateOfBirth.getTime() / 1000));
|
||||||
}
|
}
|
||||||
@@ -497,7 +501,8 @@ async function queryManticoreSql(filters, options, _reqUser) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (filters.cup) {
|
if (filters.cup) {
|
||||||
builder.select(`regex(actors.cup, '^[${filters.cup[0]}-${filters.cup[1]}]') as cup_in_range`);
|
// cup range is validated to single letters by the query parser
|
||||||
|
builder.select(`regex(actors.cup, '^[${filters.cup.min || 'A'}-${filters.cup.max || 'Z'}]') as cup_in_range`);
|
||||||
builder.where('cup_in_range', 1);
|
builder.where('cup_in_range', 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1195,6 +1200,36 @@ async function fetchMainProfile(actorId, wasCreated = false) {
|
|||||||
return fetchMainProfile(actorId, true);
|
return fetchMainProfile(actorId, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchActorGenders(actorIds) {
|
||||||
|
const actors = await knex('actors')
|
||||||
|
.select('id', 'gender')
|
||||||
|
.whereIn('id', actorIds);
|
||||||
|
|
||||||
|
return Object.fromEntries(actors.map((actor) => [actor.id, actor.gender]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// scene search documents count actors per gender, so scenes need a resync when an actor's gender changes
|
||||||
|
// syncs the search documents directly, the materialized views don't depend on actor genders
|
||||||
|
async function syncGenderChangedScenes(actorIds, gendersBefore) {
|
||||||
|
const gendersAfter = await fetchActorGenders(actorIds);
|
||||||
|
const changedActorIds = actorIds.filter((actorId) => gendersAfter[actorId] !== gendersBefore[actorId]);
|
||||||
|
|
||||||
|
if (changedActorIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sceneIds = await knex('releases_actors')
|
||||||
|
.distinct('release_id')
|
||||||
|
.whereIn('actor_id', changedActorIds)
|
||||||
|
.pluck('release_id');
|
||||||
|
|
||||||
|
logger.info(`Gender changed for ${changedActorIds.length} actors, resyncing ${sceneIds.length} scenes`);
|
||||||
|
|
||||||
|
if (sceneIds.length > 0) { // without IDs, all scenes are synced
|
||||||
|
await syncManticoreScenes(sceneIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function applyActorRevision(revisionIds, reqUser) {
|
async function applyActorRevision(revisionIds, reqUser) {
|
||||||
const revisions = await knex('actors_revisions')
|
const revisions = await knex('actors_revisions')
|
||||||
.whereIn('id', revisionIds)
|
.whereIn('id', revisionIds)
|
||||||
@@ -1276,6 +1311,7 @@ async function applyActorRevision(revisionIds, reqUser) {
|
|||||||
}, Promise.resolve());
|
}, Promise.resolve());
|
||||||
|
|
||||||
const actorIds = Array.from(new Set(revisions.map((revision) => revision.actor_id)));
|
const actorIds = Array.from(new Set(revisions.map((revision) => revision.actor_id)));
|
||||||
|
const gendersBefore = await fetchActorGenders(actorIds);
|
||||||
|
|
||||||
await interpolateProfiles(actorIds, {
|
await interpolateProfiles(actorIds, {
|
||||||
knex,
|
knex,
|
||||||
@@ -1285,7 +1321,10 @@ async function applyActorRevision(revisionIds, reqUser) {
|
|||||||
omit,
|
omit,
|
||||||
}, { refreshView: false });
|
}, { refreshView: false });
|
||||||
|
|
||||||
await syncActors(actorIds);
|
await Promise.all([
|
||||||
|
syncActors(actorIds),
|
||||||
|
syncGenderChangedScenes(actorIds, gendersBefore),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reviewActorRevision(revisionId, isApproved, { feedback }, reqUser) {
|
export async function reviewActorRevision(revisionId, isApproved, { feedback }, reqUser) {
|
||||||
|
|||||||
+110
-10
@@ -79,9 +79,12 @@ function curateScene(rawScene, assets, reqUser, context) {
|
|||||||
entityId: assets.channel.affiliate.entity_id,
|
entityId: assets.channel.affiliate.entity_id,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
actors: sortActorsByGender(assets.actors.map((actor) => curateActor(actor, {
|
actors: sortActorsByGender(assets.actors.map((actor) => ({
|
||||||
|
...curateActor(actor, {
|
||||||
sceneDate: rawScene.effective_date,
|
sceneDate: rawScene.effective_date,
|
||||||
stashes: assets.actorStashes.filter((actorStash) => actorStash.actor_id === actor.id),
|
stashes: assets.actorStashes.filter((actorStash) => actorStash.actor_id === actor.id),
|
||||||
|
}),
|
||||||
|
isUnknown: !!actor.isUnknown,
|
||||||
})), { title: rawScene.title }),
|
})), { title: rawScene.title }),
|
||||||
directors: assets.directors.map((director) => ({
|
directors: assets.directors.map((director) => ({
|
||||||
id: director.id,
|
id: director.id,
|
||||||
@@ -228,6 +231,8 @@ export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) {
|
|||||||
'countries.name as birth_country_name',
|
'countries.name as birth_country_name',
|
||||||
'countries.alias as birth_country_alias',
|
'countries.alias as birth_country_alias',
|
||||||
'releases_actors.release_id',
|
'releases_actors.release_id',
|
||||||
|
'releases_actors.id as release_actor_id',
|
||||||
|
'releases_actors.gender as release_actor_gender',
|
||||||
)
|
)
|
||||||
.leftJoin('actors', 'actors.id', 'releases_actors.actor_id')
|
.leftJoin('actors', 'actors.id', 'releases_actors.actor_id')
|
||||||
.leftJoin('actors as aliases', 'aliases.id', 'releases_actors.alias_id')
|
.leftJoin('actors as aliases', 'aliases.id', 'releases_actors.alias_id')
|
||||||
@@ -236,7 +241,7 @@ export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) {
|
|||||||
.leftJoin('media_biometrics', 'media_biometrics.media_id', 'actors.avatar_media_id')
|
.leftJoin('media_biometrics', 'media_biometrics.media_id', 'actors.avatar_media_id')
|
||||||
.leftJoin('countries', 'countries.alpha2', 'actors.birth_country_alpha2')
|
.leftJoin('countries', 'countries.alpha2', 'actors.birth_country_alpha2')
|
||||||
.whereIn('release_id', sceneIds)
|
.whereIn('release_id', sceneIds)
|
||||||
.groupBy('actors.id', 'aliases.id', 'releases_actors.release_id', 'avatars.id', 'media_biometrics.id', 'countries.name', 'countries.alias', 'sfw_media.id'),
|
.groupBy('releases_actors.id', 'actors.id', 'aliases.id', 'releases_actors.release_id', 'avatars.id', 'media_biometrics.id', 'countries.name', 'countries.alias', 'sfw_media.id'),
|
||||||
directors: knex('releases_directors')
|
directors: knex('releases_directors')
|
||||||
.whereIn('release_id', sceneIds)
|
.whereIn('release_id', sceneIds)
|
||||||
.leftJoin('actors as directors', 'directors.id', 'releases_directors.director_id'),
|
.leftJoin('actors as directors', 'directors.id', 'releases_directors.director_id'),
|
||||||
@@ -385,7 +390,15 @@ export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) {
|
|||||||
|
|
||||||
const sceneChannel = channels.find((entity) => entity.id === scene.entity_id);
|
const sceneChannel = channels.find((entity) => entity.id === scene.entity_id);
|
||||||
const sceneStudio = studios.find((entity) => entity.id === scene.studio_id);
|
const sceneStudio = studios.find((entity) => entity.id === scene.studio_id);
|
||||||
const sceneActors = actors.filter((actor) => actor.release_id === sceneId);
|
const sceneActors = actors.filter((actor) => actor.release_id === sceneId).map((actor) => (actor.id
|
||||||
|
? actor
|
||||||
|
: {
|
||||||
|
...actor,
|
||||||
|
// unidentified actor, needs a unique ID for editing, encodes gender for storage
|
||||||
|
id: `${actor.release_actor_gender || 'other'}_${actor.release_actor_id}`,
|
||||||
|
gender: actor.release_actor_gender,
|
||||||
|
isUnknown: true,
|
||||||
|
}));
|
||||||
const sceneDirectors = directors.filter((director) => director.release_id === sceneId);
|
const sceneDirectors = directors.filter((director) => director.release_id === sceneId);
|
||||||
const sceneTags = tags.filter((tag) => tag.release_id === sceneId);
|
const sceneTags = tags.filter((tag) => tag.release_id === sceneId);
|
||||||
const sceneChapters = chapters.filter((chapter) => chapter.release_id === sceneId);
|
const sceneChapters = chapters.filter((chapter) => chapter.release_id === sceneId);
|
||||||
@@ -439,6 +452,7 @@ function curateOptions(options) {
|
|||||||
aggregateActors: (options?.aggregate ?? true) && (options?.aggregateActors ?? true),
|
aggregateActors: (options?.aggregate ?? true) && (options?.aggregateActors ?? true),
|
||||||
aggregateTags: (options?.aggregate ?? true) && (options?.aggregateTags ?? true),
|
aggregateTags: (options?.aggregate ?? true) && (options?.aggregateTags ?? true),
|
||||||
aggregateChannels: (options?.aggregate ?? true) && (options?.aggregateChannels ?? true),
|
aggregateChannels: (options?.aggregate ?? true) && (options?.aggregateChannels ?? true),
|
||||||
|
aggregateActorCounts: (options?.aggregate ?? true) && (options?.aggregateActorCounts ?? true),
|
||||||
dedupe: !!options?.dedupe,
|
dedupe: !!options?.dedupe,
|
||||||
index: options?.index || 'scenes',
|
index: options?.index || 'scenes',
|
||||||
useSql: options?.useSql || (typeof options?.useSql === 'undefined' && sqlImplied.includes(options?.index)) || false,
|
useSql: options?.useSql || (typeof options?.useSql === 'undefined' && sqlImplied.includes(options?.index)) || false,
|
||||||
@@ -446,12 +460,12 @@ function curateOptions(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// function curateFacet(results, field, count = 'count(distinct id)') {
|
// function curateFacet(results, field, count = 'count(distinct id)') {
|
||||||
function curateFacet(results, field) {
|
function curateFacet(results, field, { includeZero = false } = {}) {
|
||||||
return results
|
return results
|
||||||
.find((result) => result.columns[0][field] && (result.columns[1]['count(distinct id)'] || result.columns[1]['count(*)']))
|
.find((result) => result.columns[0][field] && (result.columns[1]['count(distinct id)'] || result.columns[1]['count(*)']))
|
||||||
?.data
|
?.data
|
||||||
.map((row) => ({ key: row[field], doc_count: row['count(distinct id)'] || row['count(*)'] }))
|
.map((row) => ({ key: row[field], doc_count: row['count(distinct id)'] || row['count(*)'] }))
|
||||||
.filter((row) => !!row.key)
|
.filter((row) => !!row.key || (includeZero && row.key === 0)) // zero is a meaningful actor count, e.g. no men
|
||||||
|| [];
|
|| [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,7 +506,11 @@ async function queryManticoreSql(filters, options, _reqUser) {
|
|||||||
:actorsFacet:
|
:actorsFacet:
|
||||||
:tagsFacet:
|
:tagsFacet:
|
||||||
:channelsFacet:
|
:channelsFacet:
|
||||||
:studiosFacet:;
|
:studiosFacet:
|
||||||
|
:groupFacet:
|
||||||
|
:femalesFacet:
|
||||||
|
:malesFacet:
|
||||||
|
:transFacet:;
|
||||||
show meta;
|
show meta;
|
||||||
`, {
|
`, {
|
||||||
query: knexManticore(filters.stashId ? 'scenes_stashed' : 'scenes')
|
query: knexManticore(filters.stashId ? 'scenes_stashed' : 'scenes')
|
||||||
@@ -502,6 +520,11 @@ async function queryManticoreSql(filters, options, _reqUser) {
|
|||||||
scenes.id as id,
|
scenes.id as id,
|
||||||
scenes.title as title,
|
scenes.title as title,
|
||||||
scenes.actor_ids as actor_ids,
|
scenes.actor_ids as actor_ids,
|
||||||
|
scenes.actor_count as actor_count,
|
||||||
|
scenes.female_count as female_count,
|
||||||
|
scenes.male_count as male_count,
|
||||||
|
scenes.trans_count as trans_count,
|
||||||
|
scenes.unidentified_count as unidentified_count,
|
||||||
scenes.entity_ids as entity_ids,
|
scenes.entity_ids as entity_ids,
|
||||||
scenes.tag_ids as tag_ids,
|
scenes.tag_ids as tag_ids,
|
||||||
scenes.channel_id as channel_id,
|
scenes.channel_id as channel_id,
|
||||||
@@ -562,6 +585,23 @@ async function queryManticoreSql(filters, options, _reqUser) {
|
|||||||
builder.whereNotIn('actor_ids', filters.notActorIds);
|
builder.whereNotIn('actor_ids', filters.notActorIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// actor counts include unidentified actors, gender counts only actors with a known gender
|
||||||
|
Object.entries({
|
||||||
|
actor_count: filters.actorCount,
|
||||||
|
female_count: filters.femaleCount,
|
||||||
|
male_count: filters.maleCount,
|
||||||
|
trans_count: filters.transCount,
|
||||||
|
unidentified_count: filters.unidentifiedCount,
|
||||||
|
}).forEach(([field, range]) => {
|
||||||
|
if (typeof range?.min === 'number') {
|
||||||
|
builder.where(`scenes.${field}`, '>=', range.min);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof range?.max === 'number') {
|
||||||
|
builder.where(`scenes.${field}`, '<=', range.max);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (filters.entityId) {
|
if (filters.entityId) {
|
||||||
builder.whereRaw('any(entity_ids) = ?', filters.entityId);
|
builder.whereRaw('any(entity_ids) = ?', filters.entityId);
|
||||||
|
|
||||||
@@ -644,6 +684,10 @@ async function queryManticoreSql(filters, options, _reqUser) {
|
|||||||
tagsFacet: options.aggregateTags ? knex.raw('facet scenes.assigned_tag_ids as tags_facet distinct id order by count(*) desc limit ?', [aggSize]) : null,
|
tagsFacet: options.aggregateTags ? knex.raw('facet scenes.assigned_tag_ids as tags_facet distinct id order by count(*) desc limit ?', [aggSize]) : null,
|
||||||
channelsFacet: options.aggregateChannels ? knex.raw('facet scenes.channel_id as channels_facet distinct id order by count(*) desc limit ?', [aggSize]) : null,
|
channelsFacet: options.aggregateChannels ? knex.raw('facet scenes.channel_id as channels_facet distinct id order by count(*) desc limit ?', [aggSize]) : null,
|
||||||
studiosFacet: options.aggregateChannels ? knex.raw('facet scenes.studio_id as studios_facet distinct id order by count(*) desc limit ?', [aggSize]) : null,
|
studiosFacet: options.aggregateChannels ? knex.raw('facet scenes.studio_id as studios_facet distinct id order by count(*) desc limit ?', [aggSize]) : null,
|
||||||
|
groupFacet: options.aggregateActorCounts ? knex.raw('facet scenes.actor_count as group_facet order by actor_count asc limit ?', [aggSize]) : null,
|
||||||
|
femalesFacet: options.aggregateActorCounts ? knex.raw('facet scenes.female_count as females_facet order by female_count asc limit ?', [aggSize]) : null,
|
||||||
|
malesFacet: options.aggregateActorCounts ? knex.raw('facet scenes.male_count as males_facet order by male_count asc limit ?', [aggSize]) : null,
|
||||||
|
transFacet: options.aggregateActorCounts ? knex.raw('facet scenes.trans_count as trans_facet order by trans_count asc 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();
|
||||||
@@ -667,6 +711,13 @@ async function queryManticoreSql(filters, options, _reqUser) {
|
|||||||
const channelIds = curateFacet(results, 'channels_facet');
|
const channelIds = curateFacet(results, 'channels_facet');
|
||||||
const studioIds = curateFacet(results, 'studios_facet');
|
const studioIds = curateFacet(results, 'studios_facet');
|
||||||
|
|
||||||
|
const actorCounts = {
|
||||||
|
group: curateFacet(results, 'group_facet', { includeZero: true }),
|
||||||
|
females: curateFacet(results, 'females_facet', { includeZero: true }),
|
||||||
|
males: curateFacet(results, 'males_facet', { includeZero: true }),
|
||||||
|
trans: curateFacet(results, 'trans_facet', { includeZero: true }),
|
||||||
|
};
|
||||||
|
|
||||||
const allTagIds = mergePackedTags(tagIds);
|
const allTagIds = mergePackedTags(tagIds);
|
||||||
|
|
||||||
const actorTagIds = mergePackedTags(tagIds.filter((tag) => {
|
const actorTagIds = mergePackedTags(tagIds.filter((tag) => {
|
||||||
@@ -696,6 +747,7 @@ async function queryManticoreSql(filters, options, _reqUser) {
|
|||||||
actorTagIds,
|
actorTagIds,
|
||||||
channelIds,
|
channelIds,
|
||||||
studioIds,
|
studioIds,
|
||||||
|
actorCounts,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -708,6 +760,14 @@ function countAggregations(buckets) {
|
|||||||
return Object.fromEntries(buckets.map((bucket) => [bucket.key, { count: bucket.doc_count }]));
|
return Object.fromEntries(buckets.map((bucket) => [bucket.key, { count: bucket.doc_count }]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// URL parameter and aggregation key to filter key
|
||||||
|
const actorCountFilters = {
|
||||||
|
group: 'actorCount',
|
||||||
|
females: 'femaleCount',
|
||||||
|
males: 'maleCount',
|
||||||
|
trans: 'transCount',
|
||||||
|
};
|
||||||
|
|
||||||
export async function fetchScenes(filters, rawOptions, reqUser, context) {
|
export async function fetchScenes(filters, rawOptions, reqUser, context) {
|
||||||
const options = curateOptions(rawOptions);
|
const options = curateOptions(rawOptions);
|
||||||
|
|
||||||
@@ -716,15 +776,42 @@ export async function fetchScenes(filters, rawOptions, reqUser, context) {
|
|||||||
console.log('options', options);
|
console.log('options', options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// an active count filter would narrow its own range to the filtered value, get the unfiltered ranges from a lightweight parallel query (~10ms vs 40-500ms for the main query with its actor and tag facets)
|
||||||
|
const activeCountKeys = options.aggregateActorCounts
|
||||||
|
? Object.keys(actorCountFilters).filter((key) => filters[actorCountFilters[key]])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const countsFilters = Object.fromEntries(Object.entries(filters).filter(([key]) => !Object.values(actorCountFilters).includes(key)));
|
||||||
|
|
||||||
|
const countsOptions = {
|
||||||
|
...options,
|
||||||
|
limit: 1,
|
||||||
|
page: 1,
|
||||||
|
aggregateYears: false,
|
||||||
|
aggregateActors: false,
|
||||||
|
aggregateTags: false,
|
||||||
|
aggregateChannels: false,
|
||||||
|
};
|
||||||
|
|
||||||
console.time('manticore sql');
|
console.time('manticore sql');
|
||||||
|
|
||||||
const result = await queryManticoreSql(filters, options, reqUser);
|
const [result, countsResult] = await Promise.all([
|
||||||
|
queryManticoreSql(filters, options, reqUser),
|
||||||
|
activeCountKeys.length > 0 ? queryManticoreSql(countsFilters, countsOptions, reqUser) : null,
|
||||||
|
]);
|
||||||
|
|
||||||
console.timeEnd('manticore sql');
|
console.timeEnd('manticore sql');
|
||||||
|
|
||||||
const aggYears = options.aggregateYears && result.aggregations.years.map((bucket) => ({ year: bucket.key, count: bucket.doc_count }));
|
const aggYears = options.aggregateYears && result.aggregations.years.map((bucket) => ({ year: bucket.key, count: bucket.doc_count }));
|
||||||
const entityIds = options.aggregateChannels && [...(result.aggregations.channelIds || []), ...(result.aggregations.studioIds || [])];
|
const entityIds = options.aggregateChannels && [...(result.aggregations.channelIds || []), ...(result.aggregations.studioIds || [])];
|
||||||
|
|
||||||
|
// keyed by URL parameter, e.g. { group: [{ value: 2, count: 1234 }, ...], females: [...] }, sorted by value so the first and last bucket give the range
|
||||||
|
// filtered counts take their range from the query without count filters, so with several count filters, each of their ranges ignores the others
|
||||||
|
const aggActorCounts = options.aggregateActorCounts && Object.fromEntries(Object.keys(actorCountFilters).map((key) => [
|
||||||
|
key,
|
||||||
|
(activeCountKeys.includes(key) ? countsResult : result).aggregations.actorCounts[key].map((bucket) => ({ value: bucket.key, count: bucket.doc_count })),
|
||||||
|
]));
|
||||||
|
|
||||||
const actorCounts = options.aggregateActors && countAggregations(result.aggregations?.actorIds);
|
const actorCounts = options.aggregateActors && countAggregations(result.aggregations?.actorIds);
|
||||||
const tagCounts = options.aggregateTags && countAggregations(result.aggregations?.tagIds);
|
const tagCounts = options.aggregateTags && countAggregations(result.aggregations?.tagIds);
|
||||||
const channelCounts = options.aggregateChannels && countAggregations(entityIds);
|
const channelCounts = options.aggregateChannels && countAggregations(entityIds);
|
||||||
@@ -752,6 +839,7 @@ export async function fetchScenes(filters, rawOptions, reqUser, context) {
|
|||||||
aggTags,
|
aggTags,
|
||||||
aggActorTags,
|
aggActorTags,
|
||||||
aggChannels,
|
aggChannels,
|
||||||
|
aggActorCounts,
|
||||||
total: result.total,
|
total: result.total,
|
||||||
limit: options.limit,
|
limit: options.limit,
|
||||||
};
|
};
|
||||||
@@ -809,7 +897,7 @@ export async function fetchSceneRevisions(revisionId, filters = {}, reqUser) {
|
|||||||
...(revision.deltas.find((delta) => delta.key === 'actors')?.value || []),
|
...(revision.deltas.find((delta) => delta.key === 'actors')?.value || []),
|
||||||
...revision.base.tags.map((tag) => tag.actorId),
|
...revision.base.tags.map((tag) => tag.actorId),
|
||||||
...revision.deltas.find((delta) => delta.key === 'tags')?.value.map((tag) => tag.actorId) || [],
|
...revision.deltas.find((delta) => delta.key === 'tags')?.value.map((tag) => tag.actorId) || [],
|
||||||
].filter(Boolean))));
|
].filter((actorId) => typeof actorId === 'number')))); // unidentified actors have string IDs
|
||||||
|
|
||||||
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 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 || [])])));
|
||||||
@@ -855,10 +943,22 @@ async function applySceneActorsDelta(sceneId, delta, trx) {
|
|||||||
|
|
||||||
if (delta.value.length > 0) {
|
if (delta.value.length > 0) {
|
||||||
await knexOwner('releases_actors')
|
await knexOwner('releases_actors')
|
||||||
.insert(delta.value.map((actorId) => ({
|
.insert(delta.value.map((actorId) => {
|
||||||
|
if (typeof actorId === 'number') {
|
||||||
|
return {
|
||||||
release_id: sceneId,
|
release_id: sceneId,
|
||||||
actor_id: actorId,
|
actor_id: actorId,
|
||||||
})))
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const gender = actorId.match(/^([a-z]+)_/)?.[1];
|
||||||
|
|
||||||
|
return {
|
||||||
|
release_id: sceneId,
|
||||||
|
actor_id: null,
|
||||||
|
gender: ['male', 'female', 'transsexual'].includes(gender) ? gender : null,
|
||||||
|
};
|
||||||
|
}))
|
||||||
.transacting(trx);
|
.transacting(trx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
@@ -117,6 +117,12 @@ export async function syncManticoreScenes(sceneIds) {
|
|||||||
grandparents.id as parent_network_id,
|
grandparents.id as parent_network_id,
|
||||||
COALESCE(JSON_AGG(DISTINCT (actors.id, actors.name)) FILTER (WHERE actors.id IS NOT NULL), '[]') as actors,
|
COALESCE(JSON_AGG(DISTINCT (actors.id, actors.name)) FILTER (WHERE actors.id IS NOT NULL), '[]') as actors,
|
||||||
COALESCE(JSON_AGG(DISTINCT (actors_aliases.id, actors_aliases.name)) FILTER (WHERE actors_aliases.id IS NOT NULL), '[]') as actors_aliases,
|
COALESCE(JSON_AGG(DISTINCT (actors_aliases.id, actors_aliases.name)) FILTER (WHERE actors_aliases.id IS NOT NULL), '[]') as actors_aliases,
|
||||||
|
-- count release actor rows rather than actors to include unidentified actors, distinct because the other joins multiply rows
|
||||||
|
COUNT(DISTINCT local_actors.id) as actor_count,
|
||||||
|
COUNT(DISTINCT local_actors.id) FILTER (WHERE COALESCE(actors.gender, local_actors.gender) = 'female') as female_count,
|
||||||
|
COUNT(DISTINCT local_actors.id) FILTER (WHERE COALESCE(actors.gender, local_actors.gender) = 'male') as male_count,
|
||||||
|
COUNT(DISTINCT local_actors.id) FILTER (WHERE COALESCE(actors.gender, local_actors.gender) = 'transsexual') as trans_count,
|
||||||
|
COUNT(DISTINCT local_actors.id) FILTER (WHERE local_actors.actor_id IS NULL) as unidentified_count,
|
||||||
COALESCE(JSON_AGG(DISTINCT (tags.id, tags.name, tags.priority, tags_aliases.name, local_tags.actor_id)) FILTER (WHERE tags.id IS NOT NULL), '[]') as tags,
|
COALESCE(JSON_AGG(DISTINCT (tags.id, tags.name, tags.priority, tags_aliases.name, local_tags.actor_id)) FILTER (WHERE tags.id IS NOT NULL), '[]') as tags,
|
||||||
COALESCE(JSON_AGG(DISTINCT (movies.id, movies.title)) FILTER (WHERE movies.id IS NOT NULL), '[]') as movies,
|
COALESCE(JSON_AGG(DISTINCT (movies.id, movies.title)) FILTER (WHERE movies.id IS NOT NULL), '[]') as movies,
|
||||||
COALESCE(JSON_AGG(DISTINCT (series.id, series.title)) FILTER (WHERE series.id IS NOT NULL), '[]') as series,
|
COALESCE(JSON_AGG(DISTINCT (series.id, series.title)) FILTER (WHERE series.id IS NOT NULL), '[]') as series,
|
||||||
@@ -240,6 +246,11 @@ export async function syncManticoreScenes(sceneIds) {
|
|||||||
entity_ids: [scene.channel_id, scene.network_id, scene.parent_network_id, scene.studio_id].filter(Boolean), // manticore does not support OR, this allows IN
|
entity_ids: [scene.channel_id, scene.network_id, scene.parent_network_id, scene.studio_id].filter(Boolean), // manticore does not support OR, this allows IN
|
||||||
actor_ids: scene.actors.map((actor) => actor.f1), // don't include aliases in ID or they would show up in filters
|
actor_ids: scene.actors.map((actor) => actor.f1), // don't include aliases in ID or they would show up in filters
|
||||||
actors: Array.from(new Set([...scene.actors.map((actor) => actor.f2), ...scene.actors_aliases.map((actor) => actor.f2)])).join(),
|
actors: Array.from(new Set([...scene.actors.map((actor) => actor.f2), ...scene.actors_aliases.map((actor) => actor.f2)])).join(),
|
||||||
|
actor_count: Number(scene.actor_count), // postgres returns counts as bigint strings
|
||||||
|
female_count: Number(scene.female_count),
|
||||||
|
male_count: Number(scene.male_count),
|
||||||
|
trans_count: Number(scene.trans_count),
|
||||||
|
unidentified_count: Number(scene.unidentified_count),
|
||||||
tag_ids: Array.from(new Set(scene.tags.map((tag) => tag.f1))),
|
tag_ids: Array.from(new Set(scene.tags.map((tag) => tag.f1))),
|
||||||
assigned_tag_ids: assignedTagIds,
|
assigned_tag_ids: assignedTagIds,
|
||||||
tags: flatTags.join(' '), // only make top tags searchable to minimize cluttered results
|
tags: flatTags.join(' '), // only make top tags searchable to minimize cluttered results
|
||||||
|
|||||||
+6
-5
@@ -1,6 +1,8 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import omit from 'object.omit';
|
import omit from 'object.omit';
|
||||||
|
|
||||||
|
import { parseLetterRange, parseRange } from '../../utils/parse-range.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createActor,
|
createActor,
|
||||||
createActorRevision,
|
createActorRevision,
|
||||||
@@ -10,21 +12,20 @@ import {
|
|||||||
mergeActors,
|
mergeActors,
|
||||||
reviewActorRevision,
|
reviewActorRevision,
|
||||||
} from '../actors.js';
|
} from '../actors.js';
|
||||||
|
|
||||||
import { fetchStashByUsernameAndSlug } from '../stashes.js';
|
import { fetchStashByUsernameAndSlug } from '../stashes.js';
|
||||||
|
|
||||||
export function curateActorsQuery(query) {
|
export function curateActorsQuery(query) {
|
||||||
return {
|
return {
|
||||||
query: query.q,
|
query: query.q,
|
||||||
gender: query.gender,
|
gender: query.gender,
|
||||||
age: query.age?.split(',').map((age) => Number(age)),
|
age: parseRange(query.age),
|
||||||
dateOfBirth: query.dob && new Date(query.dob),
|
dateOfBirth: query.dob && new Date(query.dob),
|
||||||
dobType: ({ dob: 'dateOfBirth', bd: 'birthday' })[query.dobt] || 'birthday',
|
dobType: ({ dob: 'dateOfBirth', bd: 'birthday' })[query.dobt] || 'birthday',
|
||||||
cup: query.cup?.split(','),
|
cup: parseLetterRange(query.cup),
|
||||||
country: query.c,
|
country: query.c,
|
||||||
naturalBoobs: query.nb,
|
naturalBoobs: query.nb,
|
||||||
height: query.height?.split(',').map((height) => Number(height)),
|
height: parseRange(query.height),
|
||||||
weight: query.weight?.split(',').map((weight) => Number(weight)),
|
weight: parseRange(query.weight),
|
||||||
requireAvatar: query.avatar,
|
requireAvatar: query.avatar,
|
||||||
stashId: Number(query.stashId) || null,
|
stashId: Number(query.stashId) || null,
|
||||||
isGlobal: Object.hasOwn(query, 'global'),
|
isGlobal: Object.hasOwn(query, 'global'),
|
||||||
|
|||||||
+9
-1
@@ -1,7 +1,8 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
|
||||||
import promiseProps from '../../utils/promise-props.js';
|
import { parseRange } from '../../utils/parse-range.js';
|
||||||
|
|
||||||
|
import promiseProps from '../../utils/promise-props.js';
|
||||||
import slugify from '../../utils/slugify.js';
|
import slugify from '../../utils/slugify.js';
|
||||||
|
|
||||||
import { getIdsBySlug } from '../cache.js';
|
import { getIdsBySlug } from '../cache.js';
|
||||||
@@ -41,6 +42,11 @@ export async function curateScenesQuery(query) {
|
|||||||
years: splitYears.map((year) => Number(year)).filter(Boolean) || [],
|
years: splitYears.map((year) => Number(year)).filter(Boolean) || [],
|
||||||
actorIds: [query.actorId, ...splitActors.filter((actor) => actor.charAt(0) !== '!').map((identifier) => parseActorIdentifier(identifier)?.id)].filter(Boolean),
|
actorIds: [query.actorId, ...splitActors.filter((actor) => actor.charAt(0) !== '!').map((identifier) => parseActorIdentifier(identifier)?.id)].filter(Boolean),
|
||||||
notActorIds: splitActors.filter((actor) => actor.charAt(0) === '!').map((identifier) => parseActorIdentifier(identifier.slice(1))?.id).filter(Boolean),
|
notActorIds: splitActors.filter((actor) => actor.charAt(0) === '!').map((identifier) => parseActorIdentifier(identifier.slice(1))?.id).filter(Boolean),
|
||||||
|
actorCount: parseRange(query.group),
|
||||||
|
femaleCount: parseRange(query.females),
|
||||||
|
maleCount: parseRange(query.males),
|
||||||
|
transCount: parseRange(query.trans),
|
||||||
|
unidentifiedCount: parseRange(query.unidentified), // for curators, e.g. unidentified=1- finds scenes with unidentified actors
|
||||||
tagIds,
|
tagIds,
|
||||||
notTagIds: notTagIds.filter((tagId) => !tagIds.includes(tagId)), // included tags get priority over excluded tags
|
notTagIds: notTagIds.filter((tagId) => !tagIds.includes(tagId)), // included tags get priority over excluded tags
|
||||||
onlyActorTags: !!query.at,
|
onlyActorTags: !!query.at,
|
||||||
@@ -61,6 +67,7 @@ async function fetchScenesApi(req, res) {
|
|||||||
aggTags,
|
aggTags,
|
||||||
aggActorTags,
|
aggActorTags,
|
||||||
aggChannels,
|
aggChannels,
|
||||||
|
aggActorCounts,
|
||||||
limit,
|
limit,
|
||||||
total,
|
total,
|
||||||
} = await fetchScenes(await curateScenesQuery({
|
} = await fetchScenes(await curateScenesQuery({
|
||||||
@@ -80,6 +87,7 @@ async function fetchScenesApi(req, res) {
|
|||||||
aggTags,
|
aggTags,
|
||||||
aggActorTags,
|
aggActorTags,
|
||||||
aggChannels,
|
aggChannels,
|
||||||
|
aggActorCounts,
|
||||||
limit,
|
limit,
|
||||||
total,
|
total,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import argv from '../src/argv.js';
|
|||||||
|
|
||||||
import { knexOwner as knex } from '../src/knex.js';
|
import { knexOwner as knex } from '../src/knex.js';
|
||||||
import { utilsApi } from '../src/manticore.js';
|
import { utilsApi } from '../src/manticore.js';
|
||||||
|
import redis from '../src/redis.js';
|
||||||
import { syncManticoreActors } from '../src/sync.js';
|
import { syncManticoreActors } from '../src/sync.js';
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
@@ -34,12 +35,14 @@ async function init() {
|
|||||||
|
|
||||||
console.log('data', data);
|
console.log('data', data);
|
||||||
|
|
||||||
knex.destroy();
|
await knex.destroy();
|
||||||
|
await redis.quit(); // sync imports connect to redis, which keeps the process alive
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
knex.destroy();
|
await knex.destroy();
|
||||||
|
await redis.quit(); // sync imports connect to redis, which keeps the process alive
|
||||||
}
|
}
|
||||||
|
|
||||||
init();
|
init();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import argv from '../src/argv.js';
|
import argv from '../src/argv.js';
|
||||||
import { knexOwner as knex } from '../src/knex.js';
|
import { knexOwner as knex } from '../src/knex.js';
|
||||||
import { utilsApi } from '../src/manticore.js';
|
import { utilsApi } from '../src/manticore.js';
|
||||||
|
import redis from '../src/redis.js';
|
||||||
import { syncManticoreMovies } from '../src/sync.js';
|
import { syncManticoreMovies } from '../src/sync.js';
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
@@ -39,7 +40,8 @@ async function init() {
|
|||||||
console.log('data', data);
|
console.log('data', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
knex.destroy();
|
await knex.destroy();
|
||||||
|
await redis.quit(); // sync imports connect to redis, which keeps the process alive
|
||||||
}
|
}
|
||||||
|
|
||||||
init();
|
init();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import argv from '../src/argv.js';
|
import argv from '../src/argv.js';
|
||||||
import { knexOwner as knex } from '../src/knex.js';
|
import { knexOwner as knex } from '../src/knex.js';
|
||||||
import { utilsApi } from '../src/manticore.js';
|
import { utilsApi } from '../src/manticore.js';
|
||||||
|
import redis from '../src/redis.js';
|
||||||
import { syncManticoreScenes } from '../src/sync.js';
|
import { syncManticoreScenes } from '../src/sync.js';
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
@@ -24,6 +25,11 @@ async function init() {
|
|||||||
entity_ids multi,
|
entity_ids multi,
|
||||||
actor_ids multi,
|
actor_ids multi,
|
||||||
actors text,
|
actors text,
|
||||||
|
actor_count int,
|
||||||
|
female_count int,
|
||||||
|
male_count int,
|
||||||
|
trans_count int,
|
||||||
|
unidentified_count int,
|
||||||
tag_ids multi,
|
tag_ids multi,
|
||||||
assigned_tag_ids multi64,
|
assigned_tag_ids multi64,
|
||||||
tags text,
|
tags text,
|
||||||
@@ -57,7 +63,8 @@ async function init() {
|
|||||||
await syncManticoreScenes();
|
await syncManticoreScenes();
|
||||||
}
|
}
|
||||||
|
|
||||||
knex.destroy();
|
await knex.destroy();
|
||||||
|
await redis.quit(); // sync imports connect to redis, which keeps the process alive
|
||||||
}
|
}
|
||||||
|
|
||||||
init();
|
init();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { knexOwner as knex } from '../src/knex.js';
|
import { knexOwner as knex } from '../src/knex.js';
|
||||||
import { utilsApi } from '../src/manticore.js';
|
import { utilsApi } from '../src/manticore.js';
|
||||||
|
import redis from '../src/redis.js';
|
||||||
import { syncStashes } from '../src/sync.js';
|
import { syncStashes } from '../src/sync.js';
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
@@ -36,7 +37,8 @@ async function init() {
|
|||||||
await syncStashes('actor');
|
await syncStashes('actor');
|
||||||
await syncStashes('movie');
|
await syncStashes('movie');
|
||||||
|
|
||||||
knex.destroy();
|
await knex.destroy();
|
||||||
|
await redis.quit(); // sync imports connect to redis, which keeps the process alive
|
||||||
}
|
}
|
||||||
|
|
||||||
init();
|
init();
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
const numberPattern = /^\d+$/;
|
||||||
|
const letterPattern = /^[a-z]$/i;
|
||||||
|
|
||||||
|
// ranges: 3 is exactly 3, 2- is at least 2, -4 is at most 4, 2-4 is between 2 and 4 inclusive
|
||||||
|
// comma is accepted as a legacy separator for closed ranges, e.g. age=22,30 from older links
|
||||||
|
// values not matching the pattern invalidate the range, which keeps them safe to use in queries
|
||||||
|
export function parseRange(rangeString, { pattern = numberPattern, parse = Number } = {}) {
|
||||||
|
const parts = String(rangeString ?? '').trim().split(/[-,]/);
|
||||||
|
|
||||||
|
if (parts.length > 2 || parts.every((part) => part === '') || parts.some((part) => part !== '' && !pattern.test(part))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [minString, maxString = minString] = parts; // no separator means an exact value
|
||||||
|
|
||||||
|
return {
|
||||||
|
min: minString ? parse(minString) : null,
|
||||||
|
max: maxString ? parse(maxString) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// single letter cup sizes, matching the cup filter's regex character class
|
||||||
|
export function parseLetterRange(rangeString) {
|
||||||
|
return parseRange(rangeString, {
|
||||||
|
pattern: letterPattern,
|
||||||
|
parse: (letter) => letter.toUpperCase(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRange(range) {
|
||||||
|
if (!range || (range.min === null && range.max === null)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (range.min === range.max) {
|
||||||
|
return String(range.min);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${range.min ?? ''}-${range.max ?? ''}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user