traxxx-web/src/scenes.js

937 lines
30 KiB
JavaScript

import config from 'config';
import util from 'util'; /* eslint-disable-line no-unused-vars */
import { MerkleJson } from 'merkle-json';
import { knexQuery as knex, knexOwner, knexManticore } from './knex.js';
import { utilsApi } from './manticore.js';
import { HttpError } from './errors.js';
import { fetchActorsById, curateActor, sortActorsByGender } from './actors.js';
import { fetchTagsById } from './tags.js';
import { fetchMoviesById } from './movies.js';
import { fetchEntitiesById } from './entities.js';
import { curateStash } from './stashes.js';
import { curateMedia } from './media.js';
import escape from '../utils/escape-manticore.js';
import promiseProps from '../utils/promise-props.js';
import initLogger from './logger.js';
import { curateRevision } from './revisions.js';
const logger = initLogger();
const mj = new MerkleJson();
function getWatchUrl(scene) {
if (scene.url) {
return scene.url;
}
if (scene.channel && (scene.channel.isIndependent || scene.channel.type === 'network')) {
return scene.channel.url;
}
if (scene.network) {
return scene.network.url;
}
return null;
}
function getAffiliateUrl(scene) {
const watchUrl = getWatchUrl(scene);
if (!watchUrl) {
return null;
}
if (!scene.affiliate?.parameters) {
return scene.url;
}
const newParams = new URLSearchParams({
...Object.fromEntries(new URL(watchUrl).searchParams),
...Object.fromEntries(new URLSearchParams(scene.affiliate.parameters)),
});
return `${watchUrl}?${newParams.toString()}`;
}
function curateScene(rawScene, assets) {
if (!rawScene) {
return null;
}
const curatedScene = {
id: rawScene.id,
title: rawScene.title,
slug: rawScene.slug,
url: rawScene.url,
date: rawScene.date,
datePrecision: rawScene.date_precision,
createdAt: rawScene.created_at,
effectiveDate: rawScene.effective_date,
description: rawScene.description,
duration: rawScene.duration,
shootId: rawScene.shoot_id,
productionDate: rawScene.production_date,
channel: {
id: assets.channel.id,
slug: assets.channel.slug,
name: assets.channel.name,
type: assets.channel.type,
isIndependent: assets.channel.independent,
hasLogo: assets.channel.has_logo,
},
network: assets.channel.network_id ? {
id: assets.channel.network_id,
slug: assets.channel.network_slug,
name: assets.channel.network_name,
type: assets.channel.network_type,
hasLogo: assets.channel.network_has_logo,
} : null,
studio: assets.studio ? {
id: assets.studio.id,
slug: assets.studio.slug,
name: assets.studio.name,
type: assets.studio.type,
hasLogo: assets.studio.has_logo,
} : null,
affiliate: assets.channel.affiliate ? {
id: assets.channel.affiliate.id,
url: assets.channel.affiliate.url,
parameters: assets.channel.affiliate.parameters,
} : null,
actors: sortActorsByGender(assets.actors.map((actor) => curateActor(actor, {
sceneDate: rawScene.effective_date,
stashes: assets.actorStashes.filter((actorStash) => actorStash.actor_id === actor.id),
})), { title: rawScene.title }),
directors: assets.directors.map((director) => ({
id: director.id,
slug: director.slug,
name: director.name,
})),
tags: assets.tags.map((tag) => ({
id: tag.id,
slug: tag.slug,
name: tag.name,
priority: tag.priority,
})),
chapters: assets.chapters.map((chapter) => ({
id: chapter.id,
title: chapter.title,
time: chapter.time,
duration: chapter.duration,
poster: curateMedia(chapter.chapter_poster),
tags: chapter.chapter_tags.map((tag) => ({
id: tag.id,
name: tag.name,
slug: tag.slug,
})),
})),
qualities: rawScene.qualities?.sort((qualityA, qualityB) => qualityB - qualityA) || [],
photoCount: rawScene.photo_count,
movies: assets.movies.map((movie) => ({
id: movie.id,
slug: movie.slug,
title: movie.title,
covers: movie.movie_covers?.map((cover) => curateMedia(cover, { type: 'cover' })).toSorted((coverA, coverB) => coverA.index - coverB.index) || [],
})),
series: assets.series.map((serie) => ({
id: serie.id,
slug: serie.slug,
title: serie.title,
poster: curateMedia(serie.serie_poster, { type: 'poster' }),
})),
poster: curateMedia(assets.poster, { type: 'poster' }),
trailer: curateMedia(assets.trailer, { type: 'trailer' }),
teaser: curateMedia(assets.teaser, { type: 'teaser' }),
photos: assets.photos?.map((photo) => curateMedia(photo, { type: 'photo' })) || [],
caps: assets.caps?.map((cap) => curateMedia(cap, { type: 'cap' })) || [],
stashes: assets.stashes?.map((stash) => curateStash(stash)) || [],
createdBatchId: rawScene.created_batch_id,
updatedBatchId: rawScene.updated_batch_id,
isNew: assets.lastBatchId === rawScene.created_batch_id,
};
curatedScene.watchUrl = getAffiliateUrl(curatedScene);
return curatedScene;
}
export async function fetchScenesById(sceneIds, { reqUser, ...context } = {}) {
const {
scenes,
channels,
studios,
actors,
directors,
tags,
chapters,
movies,
series,
posters,
photos,
caps,
trailers,
teasers,
stashes,
lastBatch: { id: lastBatchId },
} = await promiseProps({
scenes: knex('releases').whereIn('releases.id', sceneIds),
channels: knex('releases')
.select(
'channels.*',
'networks.id as network_id',
'networks.slug as network_slug',
'networks.name as network_name',
'networks.type as network_type',
'networks.has_logo as network_has_logo',
knex.raw('row_to_json(affiliates) as affiliate'),
)
.whereIn('releases.id', sceneIds)
.leftJoin('entities as channels', 'channels.id', 'releases.entity_id')
.leftJoin('entities as networks', 'networks.id', 'channels.parent_id')
.leftJoin('affiliates', knex.raw('affiliates.entity_id in (channels.id, networks.id)'))
.groupBy('channels.id', 'networks.id', 'affiliates.id'),
studios: knex('releases')
.whereIn('releases.id', sceneIds)
.leftJoin('entities as studios', 'studios.id', 'releases.studio_id'),
// .leftJoin('entities as networks', 'networks.id', 'studios.parent_id'),
actors: knex('releases_actors')
.select(
'actors.*',
'actors_meta.*',
'countries.name as birth_country_name',
'countries.alias as birth_country_alias',
'releases_actors.release_id',
)
.leftJoin('actors', 'actors.id', 'releases_actors.actor_id')
.leftJoin('actors_meta', 'actors_meta.actor_id', 'actors.id')
.leftJoin('countries', 'countries.alpha2', 'actors.birth_country_alpha2')
.whereIn('release_id', sceneIds),
directors: knex('releases_directors')
.whereIn('release_id', sceneIds)
.leftJoin('actors as directors', 'directors.id', 'releases_directors.director_id'),
tags: knex('releases_tags')
.select('id', 'slug', 'name', 'priority', 'release_id')
.leftJoin('tags', 'tags.id', 'releases_tags.tag_id')
.whereNotNull('tags.id')
.whereIn('release_id', sceneIds)
.orderBy('priority', 'desc'),
chapters: knex('chapters')
.select(
'chapters.*',
knex.raw('coalesce(json_agg(tags) filter (where tags.id is not null), \'[]\') as chapter_tags'),
knex.raw('row_to_json(posters) as chapter_poster'),
)
.leftJoin('chapters_tags', 'chapters_tags.chapter_id', 'chapters.id')
.leftJoin('tags', 'tags.id', 'chapters_tags.tag_id')
.leftJoin('chapters_posters', 'chapters_posters.chapter_id', 'chapters.id')
.leftJoin('media as posters', 'posters.id', 'chapters_posters.media_id')
.whereIn('chapters.release_id', sceneIds)
.groupBy('chapters.id', 'posters.id')
.orderBy('time', 'asc'),
movies: context.includePartOf ? knex('movies_scenes')
.select('movies_scenes.scene_id', 'movies.*', knex.raw('json_agg(media) as movie_covers'))
.leftJoin('movies', 'movies.id', 'movies_scenes.movie_id')
.leftJoin('movies_covers', 'movies_covers.movie_id', 'movies.id')
.leftJoin('media', 'media.id', 'movies_covers.media_id')
.whereIn('scene_id', sceneIds)
.groupBy('movies.id', 'movies_scenes.scene_id') : [],
series: context.includePartOf ? knex('series_scenes')
.select('series_scenes.scene_id', 'series.*', knex.raw('row_to_json(media) as serie_poster'))
.leftJoin('series', 'series.id', 'series_scenes.serie_id')
.leftJoin('series_posters', 'series_posters.serie_id', 'series.id')
.leftJoin('media', 'media.id', 'series_posters.media_id')
.whereIn('scene_id', sceneIds)
.groupBy('series.id', 'series_scenes.scene_id', 'media.*') : [],
posters: knex('releases_posters')
.whereIn('release_id', sceneIds)
.leftJoin('media', 'media.id', 'releases_posters.media_id'),
photos: context.includeAssets ? knex.transaction(async (trx) => {
if (reqUser) {
await trx.select(knex.raw('set_config(\'user.id\', :userId, true)', { userId: reqUser.id }));
}
return trx('releases_photos')
.leftJoin('media', 'media.id', 'releases_photos.media_id')
.whereIn('release_id', sceneIds)
.orderBy('index');
}) : [],
caps: context.includeAssets ? knex.transaction(async (trx) => {
if (reqUser) {
await trx.select(knex.raw('set_config(\'user.id\', :userId, true)', { userId: reqUser.id }));
}
return trx('releases_caps')
.leftJoin('media', 'media.id', 'releases_caps.media_id')
.whereIn('release_id', sceneIds)
.orderBy('index');
}) : [],
trailers: context.includeAssets ? knex.transaction(async (trx) => {
if (reqUser) {
await trx.select(knex.raw('set_config(\'user.id\', :userId, true)', { userId: reqUser.id }));
}
return trx('releases_trailers')
.whereIn('release_id', sceneIds)
.leftJoin('media', 'media.id', 'releases_trailers.media_id');
}) : [],
teasers: context.includeAssets ? knex.transaction(async (trx) => {
if (reqUser) {
await trx.select(knex.raw('set_config(\'user.id\', :userId, true)', { userId: reqUser.id }));
}
return trx('releases_teasers')
.whereIn('release_id', sceneIds)
.leftJoin('media', 'media.id', 'releases_teasers.media_id');
}) : [],
lastBatch: knex('batches')
.select('id')
.where('showcased', true)
.orderBy('created_at', 'desc')
.first(),
stashes: reqUser
? knexOwner('stashes_scenes')
.leftJoin('stashes', 'stashes.id', 'stashes_scenes.stash_id')
.where('stashes.user_id', reqUser.id)
.whereIn('stashes_scenes.scene_id', sceneIds)
: [],
});
const actorStashes = reqUser && context.actorStashes
? await knexOwner('stashes_actors')
.leftJoin('stashes', 'stashes.id', 'stashes_actors.stash_id')
.where('stashes.user_id', reqUser.id)
.whereIn('stashes_actors.actor_id', actors.map((actor) => actor.id))
: [];
return sceneIds.map((sceneId) => {
const scene = scenes.find((sceneEntry) => sceneEntry.id === sceneId);
if (!scene) {
return null;
}
const sceneChannel = channels.find((entity) => entity.id === scene.entity_id);
const sceneStudio = studios.find((entity) => entity.id === scene.studio_id);
const sceneActors = actors.filter((actor) => actor.release_id === sceneId);
const sceneDirectors = directors.filter((director) => director.release_id === sceneId);
const sceneTags = tags.filter((tag) => tag.release_id === sceneId);
const sceneChapters = chapters.filter((chapter) => chapter.release_id === sceneId);
const sceneMovies = movies.filter((movie) => movie.scene_id === sceneId);
const sceneSeries = series.filter((serie) => serie.scene_id === sceneId);
const scenePoster = posters.find((poster) => poster.release_id === sceneId);
const scenePhotos = photos.filter((photo) => photo.release_id === sceneId);
const sceneCaps = caps.filter((cap) => cap.release_id === sceneId);
const sceneTrailers = trailers.find((trailer) => trailer.release_id === sceneId);
const sceneTeasers = teasers.find((teaser) => teaser.release_id === sceneId);
const sceneStashes = stashes.filter((stash) => stash.scene_id === sceneId);
const sceneActorStashes = sceneActors.map((actor) => actorStashes.find((stash) => stash.actor_id === actor.id)).filter(Boolean);
return curateScene(scene, {
channel: sceneChannel,
studio: sceneStudio,
actors: sceneActors,
directors: sceneDirectors,
tags: sceneTags,
chapters: sceneChapters,
movies: sceneMovies,
series: sceneSeries,
poster: scenePoster,
photos: scenePhotos,
caps: sceneCaps,
trailer: sceneTrailers,
teaser: sceneTeasers,
stashes: sceneStashes,
actorStashes: sceneActorStashes,
lastBatchId,
});
}).filter(Boolean);
}
const sqlImplied = ['scenes_stashed'];
function curateOptions(options) {
if (options?.limit > 100) {
throw new HttpError('Limit must be <= 100', 400);
}
return {
limit: options?.limit || 30,
page: Number(options?.page) || 1,
aggregate: options.aggregate ?? true,
aggregateYears: (options.aggregate ?? true) && (options.aggregateYears ?? true),
aggregateActors: (options.aggregate ?? true) && (options.aggregateActors ?? true),
aggregateTags: (options.aggregate ?? true) && (options.aggregateTags ?? true),
aggregateChannels: (options.aggregate ?? true) && (options.aggregateChannels ?? true),
index: options.index || 'scenes',
useSql: options.useSql || (typeof options.useSql === 'undefined' && sqlImplied.includes(options.index)) || false,
};
}
async function queryManticoreSql(filters, options, _reqUser) {
const aggSize = config.database.manticore.maxAggregateSize;
const sqlQuery = knexManticore.raw(`
:query:
OPTION
field_weights=(
title_filtered=7,
actors=10,
tags=9,
movies=7,
meta=6,
channel_name=2,
channel_slug=3,
network_name=1,
network_slug=1
),
max_matches=:maxMatches:,
max_query_time=:maxQueryTime:
:yearsFacet:
:actorsFacet:
:tagsFacet:
:channelsFacet:
:studiosFacet:;
show meta;
`, {
query: knexManticore(filters.stashId ? 'scenes_stashed' : 'scenes')
.modify((builder) => {
if (filters.stashId) {
builder.select(knex.raw(`
scenes.id as id,
scenes.title as title,
scenes.actor_ids as actor_ids,
scenes.entity_ids as entity_ids,
scenes.tag_ids as tag_ids,
scenes.channel_id as channel_id,
scenes.network_id as network_id,
scenes.effective_date as effective_date,
scenes.stashed as stashed,
scenes.created_at,
created_at as stashed_at,
year(scenes.effective_date) as effective_year,
weight() as _score
`));
builder
.innerJoin('scenes', 'scenes.id', 'scenes_stashed.scene_id')
.where('stash_id', filters.stashId);
} else {
builder.select(knex.raw(`
*,
year(scenes.effective_date) as effective_year,
weight() as _score
`));
}
if (filters.query) {
builder.whereRaw('match(\'@!title :query:\', scenes)', { query: escape(filters.query) });
}
if (filters.years?.length > 0) {
builder.whereIn('effective_year', filters.years);
}
filters.tagIds?.forEach((tagId) => {
builder.where('any(tag_ids)', tagId);
});
if (filters.notTagIds) {
builder.whereNotIn('tag_ids', filters.notTagIds);
}
filters.actorIds?.forEach((actorId) => {
builder.where('any(actor_ids)', actorId);
});
if (filters.notActorIds) {
builder.whereNotIn('actor_ids', filters.notActorIds);
}
if (filters.entityId) {
builder.whereRaw('any(entity_ids) = ?', filters.entityId);
/* manticore does not currently support OR if both left and right table are queried https://github.com/manticoresoftware/manticoresearch/issues/1978#issuecomment-2010470068
builder.where((whereBuilder) => {
whereBuilder
.where('scenes.channel_id', filters.entityId)
.orWhere('scenes.network_id', filters.entityId);
});
*/
}
if (filters.notEntityIds) {
builder.whereNotIn('entity_ids', filters.notEntityIds);
}
if (filters.movieId) {
builder.whereRaw('any(movie_ids) = ?', filters.movieId);
}
if (filters.serieId) {
builder.whereRaw('any(serie_ids) = ?', filters.serieId);
}
if (typeof filters.isShowcased === 'boolean') {
builder.where('scenes.is_showcased', filters.isShowcased);
}
if (filters.isShowcased) {
builder.where('scenes.date', '>', 0);
}
if (!filters.scope || filters.scope === 'latest') {
builder
.where('effective_date', '<=', Math.round(Date.now() / 1000))
.orderBy('scenes.effective_date', 'desc'); // can't seem to use alias if it matches column-name? behavior not fully understand, but this works
} else if (filters.scope === 'upcoming') {
builder
.where('effective_date', '>', Math.round(Date.now() / 1000))
.orderBy('scenes.effective_date', 'asc');
} else if (filters.scope === 'new') {
builder.orderBy([
{ column: 'scenes.created_at', order: 'desc' },
{ column: 'scenes.effective_date', order: 'asc' },
]);
} else if (filters.scope === 'likes') {
builder.orderBy([
{ column: 'scenes.stashed', order: 'desc' },
{ column: 'scenes.effective_date', order: 'desc' },
]);
} else if (filters.scope === 'results') {
builder.orderBy([
{ column: '_score', order: 'desc' },
{ column: 'scenes.stashed', order: 'desc' },
{ column: 'scenes.effective_date', order: 'desc' },
]);
} else if (filters.scope === 'stashed' && filters.stashId) {
builder.orderBy([
{ column: 'stashed_at', order: 'desc' },
{ column: 'scenes.effective_date', order: 'desc' },
]);
} else if (filters.scope === 'oldest') {
builder.orderBy('scenes.effective_date', 'asc');
} else {
builder.orderBy('scenes.effective_date', 'desc');
}
})
.limit(options.limit)
.offset((options.page - 1) * options.limit),
// option threads=1 fixes actors, but drastically slows down performance, wait for fix
yearsFacet: options.aggregateYears ? knex.raw('facet effective_year as years order by effective_year desc limit ?', [aggSize]) : null,
actorsFacet: options.aggregateActors ? knex.raw('facet scenes.actor_ids order by count(*) desc limit ?', [aggSize]) : null,
tagsFacet: options.aggregateTags ? knex.raw('facet scenes.tag_ids order by count(*) desc limit ?', [aggSize]) : null,
channelsFacet: options.aggregateChannels ? knex.raw('facet scenes.channel_id order by count(*) desc limit ?', [aggSize]) : null,
studiosFacet: options.aggregateChannels ? knex.raw('facet scenes.studio_id order by count(*) desc limit ?', [aggSize]) : null,
maxMatches: config.database.manticore.maxMatches,
maxQueryTime: config.database.manticore.maxQueryTime,
}).toString();
// manticore does not seem to accept table.column syntax if 'table' is primary (yet?), crude work-around
const curatedSqlQuery = filters.stashId
? sqlQuery
: sqlQuery.replace(/scenes\./g, '');
if (process.env.NODE_ENV === 'development') {
console.log(curatedSqlQuery);
}
const results = await utilsApi.sql(curatedSqlQuery);
// console.log(util.inspect(results, null, Infinity));
const years = results
.find((result) => (result.columns[0].years || result.columns[0]['scenes.years']) && result.columns[1]['count(*)'])
?.data.map((row) => ({ key: row.years || row['scenes.years'], doc_count: row['count(*)'] }))
|| [];
const actorIds = results
.find((result) => (result.columns[0].actor_ids || result.columns[0]['scenes.actor_ids']) && result.columns[1]['count(*)'])
?.data.map((row) => ({ key: row.actor_ids || row['scenes.actor_ids'], doc_count: row['count(*)'] }))
|| [];
const tagIds = results
.find((result) => (result.columns[0].tag_ids || result.columns[0]['scenes.tag_ids']) && result.columns[1]['count(*)'])
?.data.map((row) => ({ key: row.tag_ids || row['scenes.tag_ids'], doc_count: row['count(*)'] }))
|| [];
const channelIds = results
.find((result) => (result.columns[0].channel_id || result.columns[0]['scenes.channel_id']) && result.columns[1]['count(*)'])
?.data.map((row) => ({ key: row.channel_id || row['scenes.channel_id'], doc_count: row['count(*)'] }))
|| [];
const studioIds = results
.find((result) => (result.columns[0].studio_id || result.columns[0]['scenes.studio_id']) && result.columns[1]['count(*)'])
?.data
.map((row) => ({ key: row.studio_id || row['scenes.studio_id'], doc_count: row['count(*)'] }))
.filter((row) => !!row.key)
|| [];
const total = Number(results.at(-1).data.find((entry) => entry.Variable_name === 'total_found')?.Value) || 0;
return {
scenes: results[0].data,
total,
aggregations: {
years,
actorIds,
tagIds,
channelIds,
studioIds,
},
};
}
function countAggregations(buckets) {
if (!buckets) {
return null;
}
return Object.fromEntries(buckets.map((bucket) => [bucket.key, { count: bucket.doc_count }]));
}
export async function fetchScenes(filters, rawOptions, reqUser) {
const options = curateOptions(rawOptions);
console.log('filters', filters);
console.log('options', options);
console.time('manticore sql');
const result = await queryManticoreSql(filters, options, reqUser);
console.timeEnd('manticore sql');
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 actorCounts = options.aggregateActors && countAggregations(result.aggregations?.actorIds);
const tagCounts = options.aggregateTags && countAggregations(result.aggregations?.tagIds);
const channelCounts = options.aggregateChannels && countAggregations(entityIds);
console.time('fetch aggregations');
const [aggActors, aggTags, aggChannels] = await Promise.all([
options.aggregateActors ? fetchActorsById(result.aggregations.actorIds.map((bucket) => bucket.key), { order: ['slug', 'asc'], append: actorCounts }) : [],
options.aggregateTags ? fetchTagsById(result.aggregations.tagIds.map((bucket) => bucket.key), { order: [knex.raw('lower(name)'), 'asc'], append: tagCounts }) : [],
options.aggregateChannels ? fetchEntitiesById(entityIds.map((bucket) => bucket.key), { order: ['slug', 'asc'], append: channelCounts }) : [],
]);
console.timeEnd('fetch aggregations');
console.time('fetch full');
const sceneIds = result.scenes.map((scene) => Number(scene.id));
const scenes = await fetchScenesById(sceneIds, { reqUser });
console.timeEnd('fetch full');
return {
scenes,
aggYears,
aggActors,
aggTags,
aggChannels,
total: result.total,
limit: options.limit,
};
}
export async function fetchSceneRevisions(revisionId, filters = {}, reqUser) {
const limit = filters.limit || 50;
const page = filters.page || 1;
const revisions = await knexOwner('scenes_revisions')
.select(
'scenes_revisions.*',
'users.username as username',
'reviewers.username as reviewer_username',
)
.leftJoin('users', 'users.id', 'scenes_revisions.user_id')
.leftJoin('users as reviewers', 'reviewers.id', 'scenes_revisions.reviewed_by')
.modify((builder) => {
if (!['admin', 'editor'].includes(reqUser?.role) && !filters.userId && !filters.sceneId) {
builder.where('user_id', reqUser.id);
}
if (filters.userId) {
if (!['admin', 'editor'].includes(reqUser?.role) && filters.userId !== reqUser.id) {
throw new HttpError('You are not permitted to view revisions from other users.', 403);
}
builder.where('scenes_revisions.user_id', filters.userId);
}
if (revisionId) {
builder.where('scenes_revisions.id', revisionId);
return;
}
if (filters.sceneId) {
builder.where('scenes_revisions.scene_id', filters.sceneId);
}
if (filters.isFinalized === false) {
builder.whereNull('approved');
}
if (filters.isFinalized === true) {
builder.whereNotNull('approved');
}
})
.orderBy('created_at', 'desc')
.limit(limit)
.offset((page - 1) * limit);
const actorIds = Array.from(new Set(revisions.flatMap((revision) => [...revision.base.actors, ...(revision.deltas.find((delta) => delta.key === 'actors')?.value || [])])));
const tagIds = Array.from(new Set(revisions.flatMap((revision) => [...revision.base.tags, ...(revision.deltas.find((delta) => delta.key === 'tags')?.value || [])])));
const 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([
fetchActorsById(actorIds),
fetchTagsById(tagIds),
fetchMoviesById(movieIds),
]);
const curatedRevisions = revisions.map((revision) => curateRevision(revision));
return {
revisions: curatedRevisions,
revision: revisionId && curatedRevisions.find((revision) => revision.id === revisionId),
actors,
tags,
movies,
};
}
const keyMap = {
productionDate: 'production_date',
productionLocation: 'production_location',
productionCity: 'production_city',
productionState: 'production_state',
};
async function applySceneValueDelta(sceneId, delta, trx) {
return knexOwner('releases')
.where('id', sceneId)
.update(keyMap[delta.key] || delta.key, delta.value)
.transacting(trx);
}
async function applySceneActorsDelta(sceneId, delta, trx) {
await knexOwner('releases_actors')
.where('release_id', sceneId)
.delete()
.transacting(trx);
if (delta.value.length > 0) {
await knexOwner('releases_actors')
.insert(delta.value.map((actorId) => ({
release_id: sceneId,
actor_id: actorId,
})))
.transacting(trx);
}
}
async function applySceneTagsDelta(sceneId, delta, trx) {
// don't remove unidentified tags
await knexOwner('releases_tags')
.where('release_id', sceneId)
.whereNotNull('tag_id')
.delete()
.transacting(trx);
if (delta.value.length > 0) {
await knexOwner('releases_tags')
.insert(delta.value.map((tagId) => ({
release_id: sceneId,
tag_id: tagId,
source: 'editor',
})))
.transacting(trx);
}
}
async function applySceneMoviesDelta(sceneId, delta, trx) {
await knexOwner('movies_scenes')
.where('scene_id', sceneId)
.delete()
.transacting(trx);
if (delta.value.length > 0) {
await knexOwner('movies_scenes')
.insert(delta.value.map((movieId) => ({
scene_id: sceneId,
movie_id: movieId,
})))
.transacting(trx);
}
}
async function applySceneRevision(revisionIds) {
const revisions = await knexOwner('scenes_revisions')
.whereIn('id', revisionIds)
.whereNull('applied_at'); // should not re-apply revision that was already applied
await revisions.reduce(async (chain, revision) => {
await chain;
await knexOwner.transaction(async (trx) => {
await Promise.all(revision.deltas.map(async (delta) => {
if ([
'title',
'description',
'date',
'duration',
'productionDate',
'productionLocation',
'productionCity',
'productionState',
].includes(delta.key)) {
return applySceneValueDelta(revision.scene_id, delta, trx);
}
if (delta.key === 'actors') {
return applySceneActorsDelta(revision.scene_id, delta, trx);
}
if (delta.key === 'tags') {
return applySceneTagsDelta(revision.scene_id, delta, trx);
}
if (delta.key === 'movies') {
return applySceneMoviesDelta(revision.scene_id, delta, trx);
}
return null;
}));
await knexOwner('scenes_revisions')
.where('id', revision.id)
.update('applied_at', knex.fn.now());
// await trx.commit();
}).catch(async (error) => {
logger.error(`Failed to apply revision ${revision.id} on scene ${revision.scene_id}: ${error.message}`);
});
}, Promise.resolve());
}
export async function reviewSceneRevision(revisionId, isApproved, { feedback }, reqUser) {
if (!reqUser || reqUser.role === 'user') {
throw new HttpError('You are not permitted to approve revisions', 403);
}
if (typeof isApproved !== 'boolean') {
throw new HttpError('You must either approve or reject the revision', 400);
}
const updated = await knexOwner('scenes_revisions')
.where('id', revisionId)
.whereNull('approved') // don't rerun reviewed revision, must be forked into new revision instead
.whereNull('applied_at')
.update({
approved: isApproved,
reviewed_at: knex.fn.now(),
reviewed_by: reqUser.id,
feedback,
});
if (updated === 0) {
throw new HttpError('This revision was already reviewed', 409);
}
if (isApproved) {
await applySceneRevision([revisionId]);
}
}
export async function createSceneRevision(sceneId, { edits, comment, apply }, reqUser) {
const [
[scene],
openRevisions,
] = await Promise.all([
fetchScenesById([sceneId], {
reqUser,
includeAssets: true,
includePartOf: true,
}),
knexOwner('scenes_revisions')
.where('user_id', reqUser.id)
.whereNull('approved'),
]);
if (!scene) {
throw new HttpError(`No scene with ID ${sceneId} found to update`, 404);
}
if (openRevisions.length >= config.revisions.unapprovedLimit) {
throw new HttpError(`You have ${config.revisions.unapprovedLimit} unapproved revisions, please wait for approval before submitting another revision.`, 429);
}
const baseScene = Object.fromEntries(Object.entries(scene).map(([key, values]) => {
if ([
'effectiveDate',
'isNew',
'network',
'stashes',
'watchUrl',
].includes(key)) {
return null;
}
if (values?.hash) {
return [key, values.hash];
}
if (values?.id) {
return [key, values.id];
}
if (Array.isArray(values)) {
return [key, values.map((value) => value?.hash || value?.id || value)];
}
return [key, values];
}).filter(Boolean));
const deltas = Object.entries(edits).map(([key, value]) => {
if (baseScene[key] === value) {
return null;
}
if (Array.isArray(value)) {
const valueSet = new Set(value);
const baseSet = new Set(baseScene[key]);
if (valueSet.size === baseSet.size && baseScene[key].every((id) => valueSet.has(id))) {
return null;
}
return { key, value: Array.from(valueSet) };
}
return { key, value };
}).filter(Boolean);
if (deltas.length === 0) {
throw new HttpError('No effective changes provided', 400);
}
const [revisionEntry] = await knexOwner('scenes_revisions')
.insert({
user_id: reqUser.id,
scene_id: scene.id,
base: JSON.stringify(baseScene),
deltas: JSON.stringify(deltas),
hash: mj.hash({
base: baseScene,
deltas,
}),
comment,
})
.returning('id');
if (['admin', 'editor'].includes(reqUser.role) && apply) {
// don't keep the editor waiting for the revision to apply
reviewSceneRevision(revisionEntry.id, true, {}, reqUser);
}
}