Skip to content

Commit 1028d91

Browse files
committed
Implement marker export
Can't be imported yet, but the commit allows users to export a database file that contains all the markers for the current section, or the entire server.
1 parent 757eafe commit 1028d91

File tree

4 files changed

+278
-12
lines changed

4 files changed

+278
-12
lines changed

Client/Script/SectionOptionsOverlay.js

+81-11
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { $, appendChildren, buildNode, errorResponseOverlay, ServerCommand } from './Common.js';
1+
import { $, $$, appendChildren, buildNode, errorResponseOverlay, ServerCommand } from './Common.js';
22
import { Log } from '../../Shared/ConsoleLog.js';
33

44
import Animation from './inc/Animate.js';
@@ -8,30 +8,98 @@ import ThemeColors from './ThemeColors.js';
88
import ButtonCreator from './ButtonCreator.js';
99
import { MarkerEnum } from '../../Shared/PlexTypes.js';
1010
import { PlexClientState } from './PlexClientState.js';
11+
import Tooltip from './inc/Tooltip.js';
1112

1213
/** @typedef {import('./inc/Overlay').OverlayOptions} OverlayOptions */
1314

1415
class SectionOptionsOverlay {
16+
/**
17+
* The element to set focus back to when the overlay is dismissed.
18+
* @type {HTMLElement} */
1519
#focusBack;
20+
1621
constructor() { }
1722

18-
show(target) {
19-
this.#focusBack = target;
23+
/**
24+
* Initialize and show the section options overlay.
25+
* @param {HTMLElement} focusBack */
26+
show(focusBack) {
27+
this.#focusBack = focusBack;
28+
this.#showMain(false /*needsTransition*/);
29+
}
30+
31+
/**
32+
* Display the main overlay, either for the first time, or as the result of
33+
* canceling out of a specific option.
34+
* @param {boolean} needsTransition Whether an overlay is already showing, so we should smoothly transition between overlays. */
35+
#showMain(needsTransition) {
2036
const container = buildNode('div', { class : 'sectionOptionsOverlayContainer' });
2137
appendChildren(container,
2238
buildNode('h1', {}, 'Section Options'),
2339
buildNode('hr'),
24-
ButtonCreator.textButton('Import/Export markers', this.#onImportExport.bind(this), { class : 'sectionOptionsOverlayBtn' }),
40+
ButtonCreator.textButton('Export markers', this.#onExport.bind(this), { class : 'sectionOptionsOverlayBtn' }),
41+
ButtonCreator.textButton('Import markers', this.#onImport.bind(this), { class : 'sectionOptionsOverlayBtn' }),
2542
ButtonCreator.textButton(
2643
'Delete all markers',
2744
this.#onDeleteAll.bind(this),
28-
{ class : 'sectionOptionsOverlayBtn cancelSetting' }));
45+
{ class : 'sectionOptionsOverlayBtn cancelSetting' }),
46+
ButtonCreator.textButton(
47+
'Back',
48+
Overlay.dismiss,
49+
{ class : 'sectionOptionsOverlayBtn', style : 'margin-top: 20px' })
50+
);
51+
52+
const options = { dismissible : true, focusBack : this.#focusBack, noborder : true, closeButton : true };
53+
if (needsTransition) {
54+
this.#transitionOverlay(container, options);
55+
} else {
56+
Overlay.build(options, container);
57+
}
58+
}
59+
60+
/**
61+
* Overlay invoked from the 'Export Markers' action. */
62+
#onExport() {
63+
const container = buildNode('div', { class : 'sectionOptionsOverlayContainer' });
64+
appendChildren(container,
65+
buildNode('h2', {}, 'Marker Export'),
66+
buildNode('hr'),
67+
buildNode('span', {}, 'Export all markers to a database file that can be imported at a later date.'),
68+
buildNode('hr'),
69+
appendChildren(buildNode('div'),
70+
buildNode('label', { for : 'exportAll' }, 'Export all libraries '),
71+
buildNode('input', { type : 'checkbox', id : 'exportAll' })),
72+
buildNode('br'),
73+
appendChildren(buildNode('div'),
74+
ButtonCreator.textButton(
75+
'Export',
76+
this.#exportConfirmed.bind(this),
77+
{ id : 'exportConfirmBtn', class : 'overlayButton confirmSetting' }),
78+
ButtonCreator.textButton(
79+
'Back',
80+
function () { this.#showMain(true); }.bind(this),
81+
{ class : 'overlayButton' })));
82+
83+
Tooltip.setTooltip($$('label', container), 'Export markers from the entire server, not just the active library.');
84+
this.#transitionOverlay(container, { dismissible : true, focusBack : this.#focusBack });
85+
}
2986

30-
Overlay.build({ dismissible : true, focusBack : this.#focusBack, noborder : true, closeButton : true }, container);
87+
/**
88+
* Attempt to export this section's markers (or the entire server). */
89+
#exportConfirmed() {
90+
const exportAll = $('#exportAll').checked;
91+
try {
92+
window.open(`export/${exportAll ? -1 : PlexClientState.activeSection() }`);
93+
setTimeout(Overlay.dismiss, 1000);
94+
} catch (err) {
95+
errorResponseOverlay('Failed to export library markers.', err);
96+
}
3197
}
3298

33-
#onImportExport() {
34-
Log.info('Import/export!');
99+
/**
100+
* Overlay invoked from the 'Import Markers' action. */
101+
#onImport() {
102+
Log.info('Import!');
35103
Overlay.dismiss();
36104
setTimeout(() => { Overlay.show('Not Yet Implemented'); Overlay.setFocusBackElement(this.#focusBack); }, 250);
37105
}
@@ -47,8 +115,10 @@ class SectionOptionsOverlay {
47115
const okayAttr = { id : 'overlayDeleteMarker', class : 'overlayButton confirmDelete' };
48116
const okayButton = ButtonCreator.textButton('Delete', this.#deleteAllConfirmed.bind(this), okayAttr);
49117

50-
const cancelAttr = { id : 'deleteMarkerCancel', class : 'overlayButton' };
51-
const cancelButton = ButtonCreator.textButton('Cancel', Overlay.dismiss, cancelAttr);
118+
const cancelButton = ButtonCreator.textButton(
119+
'Back',
120+
function () { this.#showMain(true); }.bind(this),
121+
{ id : 'deleteMarkerCancel', class : 'overlayButton' });
52122

53123
warnText.appendChild(
54124
buildNode('span', {}, `If you're sure you want to continue, type DELETE (all caps) ` +
@@ -75,7 +145,7 @@ class SectionOptionsOverlay {
75145
warnText);
76146
this.#transitionOverlay(
77147
container,
78-
{ dismissible : true, centered : true, focusBack : this.#focusBack });
148+
{ dismissible : true, focusBack : this.#focusBack });
79149
}
80150

81151
/**

Client/Style/style.css

+2-1
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,8 @@ input:focus-visible {
400400
margin: auto;
401401
}
402402

403-
#overlayDeleteMarker {
403+
#overlayDeleteMarker,
404+
#exportConfirmBtn {
404405
margin-right: 10px;
405406
}
406407

Server/GETHandler.js

+5
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Log } from '../Shared/ConsoleLog.js';
88

99
import { GetServerState, ServerState } from './ServerState.js';
1010
import { Config } from './IntroEditorConfig.js';
11+
import DatabaseImportExport from './ImportExport.js';
1112
import { sendCompressedData } from './ServerHelpers.js';
1213
import ServerError from './ServerError.js';
1314
import { Thumbnails } from './ThumbnailManager.js';
@@ -33,6 +34,10 @@ class GETHandler {
3334
break;
3435
}
3536

37+
if (url.startsWith('/export/')) {
38+
return DatabaseImportExport.exportDatabase(res, parseInt(url.substring('/export/'.length)));
39+
}
40+
3641
const mimetype = contentType(lookup(url));
3742
if (!mimetype) {
3843
res.writeHead(404).end(`Bad MIME type: ${url}`);

Server/ImportExport.js

+190
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import { contentType, lookup } from 'mime-types';
2+
import { createReadStream, existsSync, mkdirSync, rmSync, statSync } from 'fs';
3+
import { join } from 'path';
4+
5+
import { Log } from '../Shared/ConsoleLog.js';
6+
7+
import { Config } from './IntroEditorConfig.js';
8+
import DatabaseWrapper from './DatabaseWrapper.js';
9+
import { PlexQueries } from './PlexQueryManager.js';
10+
import TransactionBuilder from './TransactionBuilder.js';
11+
12+
/** @typedef {!import('http').ServerResponse} ServerResponse */
13+
14+
/**
15+
* @typedef {Object} BackupRow
16+
* @property {string} marker_type
17+
* @property {number} start
18+
* @property {number} end
19+
* @property {number} modified_at
20+
* @property {number} created_at
21+
* @property {string} extra
22+
* @property {string} guid
23+
*/
24+
25+
/*
26+
Export table V1:
27+
28+
Keep this pretty simple as far as matching goes. Don't bother with potentially more
29+
specific matching options (matching metadata_id/section_id), just keep track of the
30+
GUID, so that it can be applied to any item in the library that might not be the exact
31+
same file, and should also persist across e.g. the Plex Dance.
32+
33+
| COLUMN | TYPE | DESCRIPTION |
34+
+--------------+---------------+----------------------------------------------------------------------+
35+
| id | INT | Autoincrement primary key |
36+
+--------------+---------------+----------------------------------------------------------------------+
37+
| marker_type | TEXT NOT NULL | The type of marker (intro/credits/etc) |
38+
+--------------+---------------+----------------------------------------------------------------------+
39+
| start | INT NOT NULL | Start timestamp of the marker (ms) |
40+
+--------------+---------------+----------------------------------------------------------------------+
41+
| end | INT NOT NULL | End timestamp of the marker (ms) |
42+
+--------------+---------------+----------------------------------------------------------------------+
43+
| modified_at | INT [NULL] | Modified date, if any (epoch seconds). Negative implies user-created |
44+
+--------------+---------------+----------------------------------------------------------------------+
45+
| created_at | INT NOT NULL | Create date (epoch seconds) |
46+
+--------------+---------------+----------------------------------------------------------------------+
47+
| extra | TEXT NOT NULL | Extra data indicating e.g. final credits, and detection version |
48+
+--------------+---------------+----------------------------------------------------------------------+
49+
| guid | TEXT NOT NULL | GUID of the associated episode/movie |
50+
+--------------+---------------+----------------------------------------------------------------------+
51+
52+
*/
53+
54+
/** Main table schema */
55+
const ExportTable = `
56+
CREATE TABLE IF NOT EXISTS markers (
57+
id INTEGER PRIMARY KEY AUTOINCREMENT,
58+
marker_type TEXT NOT NULL,
59+
start INTEGER NOT NULL,
60+
end INTEGER NOT NULL,
61+
modified_at INTEGER DEFAULT NULL,
62+
created_at INTEGER NOT NULL,
63+
extra TEXT NOT NULL,
64+
guid TEXT NOT NULL
65+
);`;
66+
67+
const CurrentSchemaVersion = 1;
68+
69+
/** Housekeeping table */
70+
const CheckVersionTable = `
71+
CREATE TABLE IF NOT EXISTS schema_version (version INTEGER);
72+
INSERT INTO schema_version (version) SELECT ${CurrentSchemaVersion} WHERE NOT EXISTS (SELECT * FROM schema_version);`;
73+
74+
/**
75+
* Static class that handles the import/export of markers
76+
*/
77+
class DatabaseImportExport {
78+
79+
/**
80+
* Exports a database of markers for the given section (or -1 for the entire library)
81+
* @param {ServerResponse} response
82+
* @param {number} sectionId */
83+
static async exportDatabase(response, sectionId) {
84+
if (isNaN(sectionId)) {
85+
return response.writeHead(400).end('Invalid section id');
86+
}
87+
88+
let sectionName = 'Server';
89+
if (sectionId != -1) {
90+
let valid = false;
91+
const sections = await PlexQueries.getLibraries();
92+
for (const section of sections) {
93+
if (section.id === sectionId) {
94+
sectionName = `${section.name.replace(/[<>:"/\\|?*]/g, '')}[${section.id}]`;
95+
valid = true;
96+
break;
97+
}
98+
}
99+
100+
if (!valid) {
101+
return response.writeHead(400).end('Invalid section id');
102+
}
103+
}
104+
105+
// Save to backup subdirectory.
106+
// TODO: cleanup on shutdown/startup?
107+
const backupDir = join(Config.projectRoot(), 'Backup', 'MarkerExports');
108+
mkdirSync(backupDir, { recursive : true });
109+
const time = new Date();
110+
const padL = (val, pad=2) => { val = val.toString(); return '0'.repeat(Math.max(0, pad - val.length)) + val; };
111+
112+
const backupName = `${sectionName}-${time.getFullYear()}.${padL(time.getMonth() + 1)}.` +
113+
`${padL(time.getDate())}-${padL(time.getHours())}.${padL(time.getMinutes())}.${padL(time.getSeconds())}.db`;
114+
const backupFullPath = join(backupDir, backupName);
115+
if (existsSync(backupFullPath)) {
116+
Log.warn(`Backup file "${backupName}" already exists, removing first...`);
117+
rmSync(backupFullPath); // Just bubble up any errors
118+
}
119+
120+
const db = await DatabaseWrapper.CreateDatabase(backupFullPath, true /*allowCreate*/);
121+
await db.run(CheckVersionTable);
122+
await db.run(ExportTable);
123+
124+
const params = { $tagId : PlexQueries.markerTagId() };
125+
let query =
126+
`SELECT t.text AS marker_type,
127+
t.time_offset AS start,
128+
t.end_time_offset AS end,
129+
t.thumb_url AS modified_at,
130+
t.created_at AS created_at,
131+
t.extra_data AS extra,
132+
m.guid AS guid
133+
FROM taggings t
134+
INNER JOIN metadata_items m ON m.id=t.metadata_item_id
135+
WHERE t.tag_id=$tagId`;
136+
137+
if (sectionId != -1) {
138+
query += ` AND m.library_section_id=$sectionId`;
139+
params.$sectionId = sectionId;
140+
}
141+
142+
/** @type {BackupRow[]} */
143+
const markers = await PlexQueries.database().all(query, params);
144+
145+
// Note: some markers might overlap with each other for the same GUID.
146+
// This is okay, since our import method should handle it gracefully.
147+
148+
const txn = new TransactionBuilder(db);
149+
for (const marker of markers) {
150+
txn.addStatement(
151+
`INSERT INTO markers
152+
(marker_type, start, end, modified_at, created_at, extra, guid) VALUES
153+
($markerType, $start, $end, $modifiedAt, $createdAt, $extra, $guid)`,
154+
{
155+
$markerType : marker.marker_type,
156+
$start : marker.start,
157+
$end : marker.end,
158+
$modifiedAt : marker.modified_at,
159+
$createdAt : marker.created_at,
160+
$extra : marker.extra,
161+
$guid : marker.guid
162+
});
163+
}
164+
165+
Log.info(`Adding ${markers.length} markers to database export.`);
166+
await txn.exec();
167+
168+
// All items have been added, close the db for writing and pipe it to the user.
169+
db.close();
170+
171+
const stats = statSync(backupFullPath);
172+
if (!stats.isFile()) {
173+
// Failed to save db file?
174+
return response.writeHead(500).end('Unable to retrieve marker database.');
175+
}
176+
177+
const mimetype = contentType(lookup(backupName));
178+
const readStream = createReadStream(backupFullPath);
179+
response.writeHead(200, {
180+
'Content-Type' : mimetype,
181+
'Content-Length' : stats.size,
182+
'Content-Disposition' : `attachment; filename="${backupName}"`,
183+
});
184+
185+
Log.info(`Successfully created marker backup.`);
186+
readStream.pipe(response);
187+
}
188+
}
189+
190+
export default DatabaseImportExport;

0 commit comments

Comments
 (0)