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