Skip to content

Commit

Permalink
Implement database import
Browse files Browse the repository at this point in the history
Implement everything necessary to get database import working, at least
for the happy path. Only basic validation has been done, so anything
here should definitely be considered beta.

Major integration notes:
* Add new overlay with a file selector, an 'apply to all libraries'
  checkbox, and a conflict resolution method.
* Add an import_db POST endpoint whose body contains the uploaded
  database.
* Implement POST body parsing. All parameters up to this point have fit
  in the query string, but that doesn't work for potentially megabytes
  of marker data. There are almost definitely libraries that can do the
  proper parsing for me, but I like to reinvent the wheel for some
  reason.
* Remove gate preventing the 'More' button from showing up by default.
* Mostly share bulkAdd implementation to do the actual restoration,
  since the underlying concept is the same.

Other changes:
* Adjust some queries that could get too large if we wanted information
  on too many items. SQL limits the number of conditions, and when
  importing markers we might need information on thousands of individual
  metadataIds. Add some checks to use a different system in those cases,
  in which we grab all items for the entire section, them filter those
  based on the ids passed in.
* Rename PurgeConflictResolution to MarkerConflictResolution, as the
  enum is now shared between purge restoration and DB import.
* Several bulkRestore fixes caught when testing bulk import:
  * Properly set 'lastAction' when checking for overlap among markers we
    want to restore.
  * Don't add markerActions to existingMarkers map when there's already
    an identical existing marker.
* Fix broken ImageTest test.
  • Loading branch information
danrahn committed Mar 6, 2023
1 parent 1028d91 commit 0bb7fba
Show file tree
Hide file tree
Showing 12 changed files with 670 additions and 96 deletions.
40 changes: 39 additions & 1 deletion Client/Script/Common.js
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,13 @@ const ServerCommand = {
* Resume a suspended Marker Editor.
* @returns {Promise<void>} */
resume : async () => jsonRequest('resume'),

/**
* Upload a database file and import the markers present into the given section.
* @param {Object} database
* @param {number} sectionId
* @param {number} resolveType */
importDatabase : async (database, sectionId, resolveType) => jsonBodyRequest('import_db', { database : database, sectionId : sectionId, resolveType : resolveType }),
};
/* eslint-enable */

Expand All @@ -246,8 +253,35 @@ async function jsonRequest(endpoint, parameters={}) {
url.searchParams.append(key, value);
}

return jsonPostCore(url);
}

/**
* Similar to jsonRequest, but expects blob data and attaches parameters to the body instead of URL parameters.
* @param {string} endpoint
* @param {Object} parameters */
async function jsonBodyRequest(endpoint, parameters={}) {
const url = new URL(endpoint, window.location.href);
const data = new FormData();
for (const [key, value] of Object.entries(parameters)) {
data.append(key, value);
}

return jsonPostCore(url, data);
}

/**
* Core method that makes a request to the server, expecting JSON in return.
* @param {URL} url The fully built URL endpoint
* @param {FormData} body The message body, if any. */
async function jsonPostCore(url, body=null) {
const init = { method : 'POST', headers : { accept : 'application/json' } };
if (body) {
init.body = body;
}

try {
const response = await (await fetch(url, { method : 'POST', headers : { accept : 'application/json' } })).json();
const response = await (await fetch(url, init)).json();
Log.verbose(response, `Response from ${url}`);
if (!response || response.Error) {

Expand Down Expand Up @@ -409,6 +443,10 @@ function errorMessage(error) {
return error.toString();
}

if (typeof error === 'string') {
return error;
}

return 'I don\'t know what went wrong, sorry :(';
}

Expand Down
12 changes: 6 additions & 6 deletions Client/Script/PurgedMarkerManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
PurgedServer,
PurgedShow,
PurgedTVSection } from './PurgedMarkerCache.js';
import { MarkerData, PurgeConflictResolution, SectionType } from '../../Shared/PlexTypes.js';
import { MarkerConflictResolution, MarkerData, SectionType } from '../../Shared/PlexTypes.js';
import ButtonCreator from './ButtonCreator.js';
import { PlexClientState } from './PlexClientState.js';
import { PlexUI } from './PlexUI.js';
Expand Down Expand Up @@ -822,13 +822,13 @@ class MoviePurgeTable extends PurgeTable {

class PurgeConflictControl {
static #resolutionDescriptions = {
[PurgeConflictResolution.Overwrite] :
[MarkerConflictResolution.Overwrite] :
`If any existing markers overlap with the restored marker, delete the existing marker.<br>` +
`This is useful if you previously tweaked Plex-generated markers and analyzing the item reset them.`,
[PurgeConflictResolution.Merge] :
[MarkerConflictResolution.Merge] :
`If any existing markers overlap with the restored marker, merge them into one marker that spans ` +
`the full length of both.`,
[PurgeConflictResolution.Ignore] :
[MarkerConflictResolution.Ignore] :
`If any existing markers overlap with the restored marker, keep the existing marker and permanently ` +
`ignore the purged marker.`,
};
Expand All @@ -844,7 +844,7 @@ class PurgeConflictControl {
};

const select = buildNode('select', { id : 'purgeResolution' }, 0, { change : resolutionTypeChange });
for (const [key, value] of Object.entries(PurgeConflictResolution)) {
for (const [key, value] of Object.entries(MarkerConflictResolution)) {
select.appendChild(buildNode('option', { value : value }, key));
}

Expand All @@ -867,7 +867,7 @@ class PurgeConflictControl {
select,
buildNode('div',
{ id : 'purgeResolutionDescription', class : 'hidden' },
PurgeConflictControl.#resolutionDescriptions[PurgeConflictResolution.Overwrite]));
PurgeConflictControl.#resolutionDescriptions[MarkerConflictResolution.Overwrite]));

return selectContainer;
}
Expand Down
21 changes: 7 additions & 14 deletions Client/Script/ResultRow.js
Original file line number Diff line number Diff line change
Expand Up @@ -329,11 +329,6 @@ class BulkActionResultRow extends ResultRow {
}
}

/** TODO: Remove once the initial 'More' options are actually implemented */
let _sectionMoreEnabled = false;
window.isSectionMoreEnabled = () => _sectionMoreEnabled;
window.setSectionMoreEnabled = (enabled) => { _sectionMoreEnabled = enabled; PlexUI.onFilterApplied(); };

/**
* A section-wide header that is displayed no matter what the current view state is (beside the blank state).
* Currently only contains the Filter entrypoint.
Expand Down Expand Up @@ -371,15 +366,13 @@ class SectionOptionsResultRow extends ResultRow {
Tooltip.setTooltip(this.#filterButton, 'No Active Filter'); // Need to seed the setTooltip, then use setText for everything else.
this.updateFilterTooltip();

if (_sectionMoreEnabled) {
this.#moreOptionsButton = ButtonCreator.fullButton(
'More...',
'settings',
'More options',
'standard',
function(_e, self) { new SectionOptionsOverlay().show(self); },
{ class : 'moreSectionOptionsBtn' });
}
this.#moreOptionsButton = ButtonCreator.fullButton(
'More...',
'settings',
'More options',
'standard',
function(_e, self) { new SectionOptionsOverlay().show(self); },
{ class : 'moreSectionOptionsBtn' });

appendChildren(row,
titleNode,
Expand Down
83 changes: 79 additions & 4 deletions Client/Script/SectionOptionsOverlay.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import Animation from './inc/Animate.js';
import Overlay from './inc/Overlay.js';
import ThemeColors from './ThemeColors.js';

import { MarkerConflictResolution, MarkerEnum } from '../../Shared/PlexTypes.js';
import ButtonCreator from './ButtonCreator.js';
import { MarkerEnum } from '../../Shared/PlexTypes.js';
import { PlexClientState } from './PlexClientState.js';
import Tooltip from './inc/Tooltip.js';

Expand Down Expand Up @@ -99,9 +99,84 @@ class SectionOptionsOverlay {
/**
* Overlay invoked from the 'Import Markers' action. */
#onImport() {
Log.info('Import!');
Overlay.dismiss();
setTimeout(() => { Overlay.show('Not Yet Implemented'); Overlay.setFocusBackElement(this.#focusBack); }, 250);
const container = buildNode('div', { class : 'sectionOptionsOverlayContainer' });
appendChildren(container,
buildNode('h2', {}, 'Marker Import'),
buildNode('hr'),
buildNode('span', {}, 'Import markers from a backed up database file to items in this library (or the entire server).'),
buildNode('hr'),
appendChildren(buildNode('div'),
buildNode('label', { for : 'databaseFile' }, 'Select a file: '),
buildNode('input', { type : 'file', accept : '.db,application/x-sqlite3', id : 'databaseFile' })),
appendChildren(buildNode('div'),
buildNode('label', { for : 'applyGlobally' }, 'Apply to all libraries: '),
buildNode('input', { type : 'checkbox', id : 'applyGlobally' })),
appendChildren(buildNode('div'),
buildNode('label', { for : 'resolutionType' }, 'Conflict Resolution Type: '),
appendChildren(buildNode('select', { id : 'resolutionType' }),
buildNode('option', { value : MarkerConflictResolution.Overwrite }, 'Overwrite'),
buildNode('option', { value : MarkerConflictResolution.Merge }, 'Merge'),
buildNode('option', { value : MarkerConflictResolution.Ignore }, 'Ignore'))),
buildNode('br'),
appendChildren(buildNode('div'),
ButtonCreator.textButton(
'Import',
this.#importConfirmed.bind(this),
{ id : 'exportConfirmBtn', class : 'overlayButton confirmSetting' }),
ButtonCreator.textButton(
'Back',
function () { this.#showMain(true); }.bind(this),
{ class : 'overlayButton' }))
);

this.#transitionOverlay(container, { dismissible : true, focusBack : this.#focusBack });
}

/**
* Upload the attached file and attempt to import all markers it contains. */
async #importConfirmed() {
/** @type {HTMLInputElement} */
const fileNode = $('#databaseFile');
const files = fileNode.files;
if (files?.length !== 1) {
return this.#flashInput(fileNode);
}

const file = files[0];
if (!file.name.endsWith('.db')) {
return this.#flashInput(fileNode);
}

if (file.size > 1024 * 1024 * 32) { // 32MB limit
errorResponseOverlay('Failed to upload and apply markers.', `File size of ${file.size} bytes is larger than 32MB limit.`);
return;
}

Log.info(file.name, `Uploading File`);
try {
const result = await ServerCommand.importDatabase(
file,
$('#applyGlobally').checked ? -1 : PlexClientState.activeSection(),
$('#resolutionType').value);

Overlay.dismiss(true /*forReshow*/);
setTimeout(() => {
Overlay.show(
`<h2>Marker Import Succeeded</h2><hr>` +
`Markers Added: ${result.added}<br>` +
`Ignored Markers (identical): ${result.identical}<br>` +
`Ignored Markers (merge/ignore/self-overlap): ${result.ignored}<br>` +
`Existing Markers Deleted (overwritten): ${result.deleted}<br>` +
`Existing Markers Modified (merged): ${result.modified}<br>`,
'Reload',
// Easier to just reload the page instead of reconciling all the newly deleted markers
() => { window.location.reload(); },
false /*dismissible*/);
Overlay.setFocusBackElement(this.#focusBack);
}, 250);
} catch (err) {
errorResponseOverlay('Failed to upload and apply markers', err);
}
}

/**
Expand Down
6 changes: 3 additions & 3 deletions Server/Commands/PurgeCommands.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { MarkerData, PurgeConflictResolution } from '../../Shared/PlexTypes.js';
import { MarkerConflictResolution, MarkerData } from '../../Shared/PlexTypes.js';
import { Log } from '../../Shared/ConsoleLog.js';

import { BackupManager } from '../MarkerBackupManager.js';
Expand Down Expand Up @@ -40,8 +40,8 @@ class PurgeCommands {
static async restoreMarkers(oldMarkerIds, sectionId, resolveType) {
PurgeCommands.#checkBackupManagerEnabled(); // TODO: Why does bulk overwrite keep the old markers around?

if (Object.keys(PurgeConflictResolution).filter(k => PurgeConflictResolution[k] == resolveType).length == 0) {
throw new ServerError(`Unexpected PurgeConflictResolution type: ${resolveType}`, 400);
if (Object.keys(MarkerConflictResolution).filter(k => MarkerConflictResolution[k] == resolveType).length == 0) {
throw new ServerError(`Unexpected MarkerConflictResolution type: ${resolveType}`, 400);
}

const restoredMarkerData = await BackupManager.restoreMarkers(oldMarkerIds, sectionId, resolveType);
Expand Down
9 changes: 8 additions & 1 deletion Server/Commands/QueryCommands.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,19 @@ class QueryCommands {
throw new ServerError(`Marker query must have at least one metadata id to search for,`, 400);
}

let sectionId;
if (keys.length > 500) {
// It's inefficient to query for 500+ individual markers. At this scale,
// get all items in a section and filter accordingly.
sectionId = (await PlexQueries.getMarkersAuto(keys[0])).markers[0].section_id;
}

const markers = {};
for (const key of keys) {
markers[key] = [];
}

const rawMarkers = await PlexQueries.getMarkersForItems(keys);
const rawMarkers = await PlexQueries.getMarkersForItems(keys, sectionId);
for (const rawMarker of rawMarkers) {
// TODO: better handing of non intros/credits (i.e. commercials)
if (supportedMarkerType(rawMarker.marker_type)) {
Expand Down
Loading

0 comments on commit 0bb7fba

Please sign in to comment.