diff --git a/assets/css/states.css b/assets/css/states.css
index 5928c02..cba281a 100755
--- a/assets/css/states.css
+++ b/assets/css/states.css
@@ -57,3 +57,7 @@
text-overflow: ellipsis;
overflow: hidden;
}
+
+.noshrink {
+ flex-shrink: 0;
+}
diff --git a/components/actors/search.vue b/components/actors/search.vue
new file mode 100644
index 0000000..b2795d9
--- /dev/null
+++ b/components/actors/search.vue
@@ -0,0 +1,115 @@
+
+
+
+
+
+
+
+
+
+ - {{ actor.name }} ({{ [actor.ageFromBirth, actor.origin?.country?.alpha2].join(', ') }})
+
+
+
+
+
+
+
+
+
+
+
diff --git a/components/edit/actors.vue b/components/edit/actors.vue
new file mode 100644
index 0000000..37c52ed
--- /dev/null
+++ b/components/edit/actors.vue
@@ -0,0 +1,153 @@
+
+
+ -
+ {{ actor.name }}
+
+
+
+ actorId !== actor.id))"
+ />
+
+
+ -
+
+
+
+
+
+
+
+
+
+
diff --git a/components/edit/tags.vue b/components/edit/tags.vue
new file mode 100644
index 0000000..43eb3eb
--- /dev/null
+++ b/components/edit/tags.vue
@@ -0,0 +1,153 @@
+
+
+
+
+
+
+
diff --git a/components/tags/search.vue b/components/tags/search.vue
new file mode 100644
index 0000000..15cd6a5
--- /dev/null
+++ b/components/tags/search.vue
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/config/default.cjs b/config/default.cjs
index 4045aa5..6e919fd 100755
--- a/config/default.cjs
+++ b/config/default.cjs
@@ -69,6 +69,9 @@ module.exports = {
keyLimit: 5, // max keys per user
keyCooldown: 1, // minutes between key generation
},
+ revisions: {
+ unapprovedLimit: 3,
+ },
psa: {
text: 'Welcome to traxxx!', // html enabled
type: 'notice', // notice, alert
diff --git a/pages/scene/+Page.vue b/pages/scene/+Page.vue
index 88e2def..b426735 100644
--- a/pages/scene/+Page.vue
+++ b/pages/scene/+Page.vue
@@ -196,6 +196,16 @@
{{ scene.shootId }}
+
+
+
+
+
+
+
+
diff --git a/pages/scene/edit/+route.js b/pages/scene/edit/+route.js
new file mode 100644
index 0000000..576b454
--- /dev/null
+++ b/pages/scene/edit/+route.js
@@ -0,0 +1 @@
+export default '/scene/@sceneId/*/edit';
diff --git a/src/api.js b/src/api.js
index d8684d3..00bd4ec 100644
--- a/src/api.js
+++ b/src/api.js
@@ -112,6 +112,7 @@ export async function patch(path, data, options = {}) {
});
if (res.status === 204) {
+ showFeedback(true, options);
return null;
}
diff --git a/src/cache.js b/src/cache.js
index 9d16153..8779729 100644
--- a/src/cache.js
+++ b/src/cache.js
@@ -1,6 +1,6 @@
import redis from './redis.js';
-export async function getIdsBySlug(slugs, domain) {
+export async function getIdsBySlug(slugs, domain, toMap) {
if (!slugs) {
return [];
}
@@ -21,5 +21,9 @@ export async function getIdsBySlug(slugs, domain) {
return Number(id);
}));
+ if (toMap) {
+ return Object.fromEntries(slugs.map((slug, index) => [slug, ids[index]]));
+ }
+
return ids.filter(Boolean);
}
diff --git a/src/media.js b/src/media.js
index 4fee8aa..2adad9b 100644
--- a/src/media.js
+++ b/src/media.js
@@ -5,6 +5,7 @@ export function curateMedia(media, context = {}) {
return {
id: media.id,
+ hash: media.hash,
path: media.path,
thumbnail: media.thumbnail,
lazy: media.lazy,
diff --git a/src/scenes.js b/src/scenes.js
index dedffe4..9a2c765 100644
--- a/src/scenes.js
+++ b/src/scenes.js
@@ -11,6 +11,9 @@ 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';
+
+const logger = initLogger();
function getWatchUrl(scene) {
if (scene.url) {
@@ -64,6 +67,7 @@ function curateScene(rawScene, assets) {
description: rawScene.description,
duration: rawScene.duration,
shootId: rawScene.shoot_id,
+ productionDate: rawScene.production_date,
channel: {
id: assets.channel.id,
slug: assets.channel.slug,
@@ -595,3 +599,180 @@ export async function fetchScenes(filters, rawOptions, reqUser) {
limit: options.limit,
};
}
+
+async function applySceneValueDelta(sceneId, delta, trx) {
+ console.log('value delta', delta);
+
+ return knexOwner('releases')
+ .where('id', sceneId)
+ .update(delta.key, delta.value)
+ .transacting(trx);
+}
+
+async function applySceneActorsDelta(sceneId, delta, trx) {
+ console.log('actors delta', delta);
+
+ await knexOwner('releases_actors')
+ .where('release_id', sceneId)
+ .delete()
+ .transacting(trx);
+
+ await knexOwner('releases_actors')
+ .insert(delta.value.map((actorId) => ({
+ release_id: sceneId,
+ actor_id: actorId,
+ })))
+ .transacting(trx);
+}
+
+async function applySceneTagsDelta(sceneId, delta, trx) {
+ console.log('tags delta', delta);
+
+ await knexOwner('releases_tags')
+ .where('release_id', sceneId)
+ .whereNotNull('tag_id')
+ .delete()
+ .transacting(trx);
+
+ await knexOwner('releases_tags')
+ .insert(delta.value.map((tagId) => ({
+ release_id: sceneId,
+ tag_id: tagId,
+ source: 'editor',
+ })))
+ .transacting(trx);
+}
+
+async function applySceneRevision(sceneIds) {
+ const revisions = await knexOwner('scenes_revisions')
+ .whereIn('scene_id', sceneIds)
+ .whereNull('applied_at');
+
+ await revisions.reduce(async (chain, revision) => {
+ await chain;
+
+ console.log('revision', revision);
+
+ await knexOwner.transaction(async (trx) => {
+ await revision.deltas.map(async (delta) => {
+ if ([
+ 'title',
+ 'description',
+ 'date',
+ 'duration',
+ 'production_date',
+ 'production_location',
+ 'production_city',
+ 'production_state',
+ ].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);
+ }
+
+ 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());
+}
+
+const keyMap = {
+ productionDate: 'production_date',
+};
+
+export async function createSceneRevision(sceneId, { edits, comment }, reqUser) {
+ const [
+ [scene],
+ openRevisions,
+ ] = await Promise.all([
+ fetchScenesById([sceneId], { reqUser, includeAssets: true }),
+ knexOwner('scenes_revisions')
+ .where('user_id', reqUser.id)
+ .whereNull('approved_by')
+ .whereNot('failed', true),
+ ]);
+
+ 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: keyMap[key] || key,
+ value,
+ };
+ }).filter(Boolean);
+
+ if (deltas.length === 0) {
+ throw new HttpError('No effective changes provided', 400);
+ }
+
+ await knexOwner('scenes_revisions').insert({
+ user_id: reqUser.id,
+ scene_id: scene.id,
+ base: JSON.stringify(baseScene),
+ deltas: JSON.stringify(deltas),
+ comment,
+ });
+
+ if (['admin', 'editor'].includes(reqUser.role)) {
+ await applySceneRevision([scene.id]);
+ }
+}
diff --git a/src/tags.js b/src/tags.js
index aed1445..7a29c63 100644
--- a/src/tags.js
+++ b/src/tags.js
@@ -47,13 +47,17 @@ export async function fetchTags(options = {}) {
column: knex.raw('similarity(aliases.slug, :query)', { query }),
order: 'desc',
},
+ {
+ column: 'aliases.priority',
+ order: 'desc',
+ },
{
column: 'aliases.slug',
order: 'asc',
},
]);
} else if (!options.includeAliases) {
- builder.whereNull('alias_for');
+ builder.whereNull('tags.alias_for');
}
}),
knex('tags_posters')
diff --git a/src/web/main.js b/src/web/main.js
index 9462128..2823eef 100644
--- a/src/web/main.js
+++ b/src/web/main.js
@@ -22,6 +22,7 @@ export default async function mainHandler(req, res, next) {
id: req.user.id,
username: req.user.username,
email: req.user.email,
+ role: req.user.role,
avatar: req.user.avatar,
},
assets: req.user ? {
diff --git a/src/web/scenes.js b/src/web/scenes.js
index d60be46..d17268c 100644
--- a/src/web/scenes.js
+++ b/src/web/scenes.js
@@ -1,9 +1,15 @@
import { stringify } from '@brillout/json-serializer/stringify'; /* eslint-disable-line import/extensions */
-import { fetchScenes, fetchScenesById } from '../scenes.js';
+import {
+ fetchScenes,
+ fetchScenesById,
+ createSceneRevision,
+} from '../scenes.js';
+
import { parseActorIdentifier } from '../query.js';
import { getIdsBySlug } from '../cache.js';
import slugify from '../../utils/slugify.js';
+import { HttpError } from '../errors.js';
import promiseProps from '../../utils/promise-props.js';
export async function curateScenesQuery(query) {
@@ -197,6 +203,18 @@ export async function fetchScenesGraphql(query, req) {
};
}
+export async function fetchSceneApi(req, res) {
+ const [scene] = await fetchScenesById([Number(req.params.sceneId)], { reqUser: req.user });
+
+ console.log(req.params.sceneId, scene);
+
+ if (!scene) {
+ throw new HttpError(`No scene with ID ${req.params.sceneId} found`, 404);
+ }
+
+ res.send(scene);
+}
+
export async function fetchScenesByIdGraphql(query, req) {
const scenes = await fetchScenesById([].concat(query.id, query.ids).filter(Boolean), {
reqUser: req.user,
@@ -209,3 +227,9 @@ export async function fetchScenesByIdGraphql(query, req) {
return scenes[0];
}
+
+export async function createSceneRevisionApi(req, res) {
+ await createSceneRevision(Number(req.params.sceneId), req.body, req.user);
+
+ res.status(204).send();
+}
diff --git a/src/web/server.js b/src/web/server.js
index 174141e..4150cbe 100644
--- a/src/web/server.js
+++ b/src/web/server.js
@@ -13,7 +13,12 @@ import redis from '../redis.js';
import errorHandler from './error.js';
import consentHandler from './consent.js';
-import { fetchScenesApi } from './scenes.js';
+import {
+ fetchScenesApi,
+ fetchSceneApi,
+ createSceneRevisionApi,
+} from './scenes.js';
+
import { fetchActorsApi } from './actors.js';
import { fetchMoviesApi } from './movies.js';
import { fetchEntitiesApi } from './entities.js';
@@ -179,6 +184,8 @@ export default async function initServer() {
// SCENES
router.get('/api/scenes', fetchScenesApi);
+ router.get('/api/scenes/:sceneId', fetchSceneApi);
+ router.patch('/api/scenes/:sceneId', createSceneRevisionApi);
// ACTORS
router.get('/api/actors', fetchActorsApi);
diff --git a/static b/static
index 514a7ac..7ed5e95 160000
--- a/static
+++ b/static
@@ -1 +1 @@
-Subproject commit 514a7accf3835913a7c168d34b996bde23dcf2d8
+Subproject commit 7ed5e9579b65904738b1322c222f35d516cf52c5
diff --git a/utils/process-summary-template.js b/utils/process-summary-template.js
index b0a480b..a2f2960 100644
--- a/utils/process-summary-template.js
+++ b/utils/process-summary-template.js
@@ -23,7 +23,7 @@ const propProcessors = {
.map((actor) => actor.name);
},
tags: (sceneInfo, options) => sceneInfo.tags
- .filter((tag) => {
+ ?.filter((tag) => {
if (options.include && !options.include.includes(tag.slug)) {
return false;
}