diff --git a/Client/Script/ClientDataExtensions.js b/Client/Script/ClientDataExtensions.js
index 4d5eaaf..adaa338 100644
--- a/Client/Script/ClientDataExtensions.js
+++ b/Client/Script/ClientDataExtensions.js
@@ -1,5 +1,7 @@
import { Log } from "../../Shared/ConsoleLog.js";
+import MarkerBreakdown from "../../Shared/MarkerBreakdown.js";
import { EpisodeData, MarkerData, MovieData, PlexData } from "../../Shared/PlexTypes.js";
+import { $$ } from "./Common.js";
import MarkerTable from "./MarkerTable.js";
import { EpisodeResultRow, MovieResultRow } from "./ResultRow.js";
@@ -43,7 +45,12 @@ class ClientMovieData extends MovieData {
* serialized {@linkcode MarkerData} for the episode. */
createMarkerTable(parentRow, serializedMarkers) {
if (this.#markerTable != null) {
- Log.warn('The marker table already exists, we shouldn\'t be creating a new one!');
+ // This is expected if the result has appeared in multiple search results.
+ // Assume we're in a good state and ignore this, but reset the parent and make
+ // sure the table is in its initial hidden state.
+ this.#markerTable.setParent(parentRow);
+ $$('table', this.#markerTable.table()).classList.add('hidden');
+ return;
}
const markers = [];
@@ -51,12 +58,9 @@ class ClientMovieData extends MovieData {
markers.push(new MarkerData().setFromJson(marker));
}
- const mov = parentRow.movie();
-
// Marker breakdown is currently overkill for movies, since it only ever has a single item inside of it.
// If intros/credits are ever separated though, this will do the right thing.
- const markerCount = mov?.markerBreakdown ? Object.keys(mov.markerBreakdown).reduce((sum, k) => sum + k * mov.markerBreakdown[k]) : 0;
- this.#markerTable = new MarkerTable(markers, parentRow, true /*lazyLoad*/, markerCount);
+ this.#markerTable = new MarkerTable(markers, parentRow, true /*lazyLoad*/, parentRow.currentKey());
}
/**
@@ -97,7 +101,7 @@ class ClientEpisodeData extends EpisodeData {
/**
* Creates the marker table for this episode.
* @param {EpisodeResultRow} parentRow The UI associated with this episode.
- * @param {{[metadataId: number]: Object[]}} serializedMarkers Map of episode ids to an array of
+ * @param {SerializedMarkerData[]} serializedMarkers Map of episode ids to an array of
* serialized {@linkcode MarkerData} for the episode. */
createMarkerTable(parentRow, serializedMarkers) {
if (this.#markerTable != null) {
@@ -109,6 +113,7 @@ class ClientEpisodeData extends EpisodeData {
markers.push(new MarkerData().setFromJson(marker));
}
+ parentRow.setCurrentKey(markers.reduce((acc, marker) => acc + MarkerBreakdown.deltaFromType(1, marker.markerType), 0));
this.#markerTable = new MarkerTable(markers, parentRow);
}
diff --git a/Client/Script/MarkerBreakdownChart.js b/Client/Script/MarkerBreakdownChart.js
index 2c3b730..16e39fc 100644
--- a/Client/Script/MarkerBreakdownChart.js
+++ b/Client/Script/MarkerBreakdownChart.js
@@ -1,11 +1,40 @@
+import MarkerBreakdown from "../../Shared/MarkerBreakdown.js";
import { $, appendChildren, buildNode, errorResponseOverlay, plural, ServerCommand } from "./Common.js";
import { Chart, PieChartOptions } from "./inc/Chart.js";
import Overlay from "./inc/Overlay.js";
import Tooltip from "./inc/Tooltip.js";
import PlexClientState from "./PlexClientState.js";
+/**
+ * Available charts
+ * @enum */
+const BreakdownType = {
+ /**@readonly*/ Combined : 0,
+ /**@readonly*/ Intros : 1,
+ /**@readonly*/ Credits : 2,
+};
+
+/**
+ * Titles for the above chart types. */
+const BreakdownTitles = {
+ [BreakdownType.Combined] : 'Marker Breakdown',
+ [BreakdownType.Intros] : 'Intro Breakdown',
+ [BreakdownType.Credits] : 'Credits Breakdown',
+};
+
+/**
+ * Tooltip labels for the given BreakdownType. */
+const DataLabels = {
+ [BreakdownType.Combined] : 'Marker',
+ [BreakdownType.Intros] : 'Intro Marker',
+ [BreakdownType.Credits] : 'Credits Marker',
+}
+
class MarkerBreakdownManager {
+ /** @type {MarkerBreakdown} */
+ #currentBreakdown = null;
+
/** Create a new marker breakdown manager for this session. */
constructor() {
const stats = $('#markerBreakdown');
@@ -18,6 +47,7 @@ class MarkerBreakdownManager {
* The initial request may take some time for large libraries, so first show an overlay
* letting the user know something's actually happening. */
async #getBreakdown() {
+ this.#currentBreakdown = null;
Overlay.show(
appendChildren(buildNode('div'),
buildNode('h2', {}, 'Marker Breakdown'),
@@ -28,8 +58,9 @@ class MarkerBreakdownManager {
'Cancel');
try {
- const markerStats = await ServerCommand.getMarkerStats(PlexClientState.GetState().activeSection());
- MarkerBreakdownManager.#showMarkerBreakdown(markerStats);
+ const rawBreakdown = await ServerCommand.getMarkerStats(PlexClientState.GetState().activeSection());
+ this.#currentBreakdown = new MarkerBreakdown().initFromRawBreakdown(rawBreakdown);
+ this.#showMarkerBreakdown(BreakdownType.Combined);
} catch (err) {
errorResponseOverlay('Failed to show breakdown', err);
}
@@ -37,26 +68,41 @@ class MarkerBreakdownManager {
/**
* Displays a pie chart of the data from the server.
- * @param {{[markerCount : number] : number }} response The marker breakdown data */
- static #showMarkerBreakdown(response) {
+ * @param {MarkerBreakdown} breakdown The marker breakdown data */
+ #showMarkerBreakdown(breakdownType) {
const overlay = $('#mainOverlay');
- if (!overlay) {
+ if (!overlay || !this.#currentBreakdown) {
Log.verbose('Overlay is gone, not showing stats');
return; // User closed out of window
}
/** @type {import("./inc/Chart").ChartDataPoint[]} */
let dataPoints = [];
- for (const [bucket, value] of Object.entries(response)) {
- dataPoints.push({ value : value, label : plural(bucket, 'Marker') });
+ let chartData;
+ switch (breakdownType) {
+ case BreakdownType.Combined:
+ chartData = this.#currentBreakdown.collapsedBuckets();
+ break;
+ case BreakdownType.Intros:
+ chartData = this.#currentBreakdown.introBuckets();
+ break;
+ case BreakdownType.Credits:
+ chartData = this.#currentBreakdown.creditsBuckets();
+ break;
+ default:
+ throw new Error(`Invalid breakdown type ${breakdownType}`);
+ }
+
+ for (const [bucket, value] of Object.entries(chartData)) {
+ dataPoints.push({ value : value, label : plural(bucket, DataLabels[breakdownType]) });
}
const radius = Math.min(Math.min(400, window.innerWidth / 2 - 40), window.innerHeight / 2 - 200);
let options = new PieChartOptions(dataPoints, radius);
- options.title = 'Marker Breakdown';
+ const chartSelect = this.#buildOptions(breakdownType);
options.colorMap = { // Set colors for 0 and 1, use defaults for everything else
- '0 Markers' : '#a33e3e',
- '1 Marker' : '#2e832e'
+ [`0 ${DataLabels[breakdownType]}s`] : '#a33e3e',
+ [`1 ${DataLabels[breakdownType]}`] : '#2e832e'
};
options.sortFn = (a, b) => parseInt(a.label) - parseInt(b.label);
options.labelOptions = { count : true, percentage : true };
@@ -69,7 +115,32 @@ class MarkerBreakdownManager {
const opacity = parseFloat(getComputedStyle(overlay).opacity);
const delay = (1 - opacity) * 250;
Overlay.build({ dismissible : true, centered : true, delay : delay, noborder : true, closeButton : true },
- appendChildren(buildNode('div', { style : 'text-align: center' }), chart));
+ appendChildren(buildNode('div', { style : 'text-align: center' }),
+ appendChildren(buildNode('div', { style : 'padding-bottom: 20px' }), chartSelect),
+ chart));
+ }
+
+ /**
+ * Build the dropdown that controls what specific chart is displayed.
+ * @param {number} breakdownType */
+ #buildOptions(breakdownType) {
+ const sel = buildNode('select', { id : 'chartBreakdownType', class : 'fancySelect' }, 0, { change : this.#onChartTypeChange.bind(this) });
+ for (const option of Object.values(BreakdownType)) {
+ const optNode = buildNode('option', { value : option }, BreakdownTitles[option]);
+ if (option == breakdownType) {
+ optNode.setAttribute('selected', 'selected');
+ }
+
+ sel.appendChild(optNode);
+ }
+
+ return sel;
+ }
+
+ /**
+ * Draw a new chart based on the option selected in the dropdown. */
+ #onChartTypeChange() {
+ this.#showMarkerBreakdown(parseInt($('#chartBreakdownType').value));
}
}
diff --git a/Client/Script/MarkerTable.js b/Client/Script/MarkerTable.js
index 5073676..dde5557 100644
--- a/Client/Script/MarkerTable.js
+++ b/Client/Script/MarkerTable.js
@@ -7,7 +7,8 @@ import Overlay from './inc/Overlay.js';
import ButtonCreator from './ButtonCreator.js';
import { ExistingMarkerRow, MarkerRow, NewMarkerRow } from './MarkerTableRow.js';
import TableElements from './TableElements.js';
-import { ResultRow } from './ResultRow.js';
+import { BaseItemResultRow } from './ResultRow.js';
+import MarkerBreakdown from '../../Shared/MarkerBreakdown.js';
/**
* The UI representation of an episode's markers. Handles adding, editing, and removing markers for a single episode.
@@ -20,7 +21,7 @@ class MarkerTable {
/**
* The episode/movie UI that this table is attached to.
- * @type {ResultRow} */
+ * @type {BaseItemResultRow} */
#parentRow;
/**
@@ -37,13 +38,14 @@ class MarkerTable {
* The number of markers we expect in this table before actually populating it.
* Only used by movies.
* @type {number?} */
- #cachedMarkerCount = undefined;
+ #cachedMarkerCountKey = undefined;
/**
* @param {MarkerData[]} markers The markers to add to this table.
- * @param {ResultRow} parentRow The episode/movie UI that this table is attached to.
- * @param {boolean} [lazyLoad=false] Whether we expect our marker data to come in later, so don't populate the table yet. */
- constructor(markers, parentRow, lazyLoad=false, cachedMarkerCount=0) {
+ * @param {BaseItemResultRow} parentRow The episode/movie UI that this table is attached to.
+ * @param {boolean} [lazyLoad=false] Whether we expect our marker data to come in later, so don't populate the table yet.
+ * @param {number} [cachedMarkerCountKey] If we're lazy loading, this captures the number of credits and intros that we expect the table to have. */
+ constructor(markers, parentRow, lazyLoad=false, cachedMarkerCountKey=0) {
this.#parentRow = parentRow;
this.#markers = markers.sort((a, b) => a.start - b.start);
let container = buildNode('div', { class : 'tableHolder' });
@@ -72,7 +74,7 @@ class MarkerTable {
rows.appendChild(markerRow.row());
}
} else {
- this.#cachedMarkerCount = cachedMarkerCount;
+ this.#cachedMarkerCountKey = cachedMarkerCountKey;
}
rows.appendChild(TableElements.spanningTableRow(ButtonCreator.textButton('Add Marker', this.#onMarkerAdd.bind(this))));
@@ -81,6 +83,18 @@ class MarkerTable {
this.#html = container;
}
+ /**
+ * Sets the new parent of this table. Used for movies, where this table
+ * is cached on the ClientMovieData, which can survive multiple searches,
+ * but the ResultRow is different every time, so this needs to be reattached.
+ * @param {BaseItemResultRow} parentRow */
+ setParent(parentRow) {
+ this.#parentRow = parentRow;
+ for (const row of this.#rows) {
+ row.setParent(parentRow);
+ }
+ }
+
/**
* @param {MarkerData[]} markers */
lazyInit(markers) {
@@ -97,26 +111,45 @@ class MarkerTable {
tbody.insertBefore(TableElements.noMarkerRow(), addMarkerRow);
}
+ let newKey = 0;
for (const marker of markers) {
const markerRow = new ExistingMarkerRow(marker, this.#parentRow);
this.#rows.push(markerRow);
tbody.insertBefore(markerRow.row(), addMarkerRow);
+ newKey += MarkerBreakdown.deltaFromType(1, marker.markerType);
}
- // Cyclical issues here. We want to check cachedMarkerCount against the marker length to
- // update the marker text if needed, but we won't get an accurate count if cachedMarkerCount
- // isn't undefined.
- const cachedCount = this.#cachedMarkerCount;
- this.#cachedMarkerCount = undefined;
- this.#parentRow.updateMarkerBreakdown(markers.length - cachedCount);
+ this.#cachedMarkerCountKey = undefined;
+ this.#parentRow.updateMarkerBreakdown();
}
+ /**
+ * Return whether this table has real data, or just a placeholder marker count. */
+ hasRealData() { return this.#cachedMarkerCountKey === undefined; }
+
/** @returns {HTMLElement} The raw HTML of the marker table. */
table() { return this.#html; }
/** @returns {number} The number of markers this episode has (not including in-progress additions). */
- markerCount() { return this.#cachedMarkerCount !== undefined ? this.#cachedMarkerCount : this.#markers.length; }
+ markerCount() {
+ if (this.#cachedMarkerCountKey === undefined) {
+ return this.#markers.length;
+ }
+
+ return MarkerBreakdown.markerCountFromKey(this.#cachedMarkerCountKey);
+ }
+
+ /** @returns {number} */
+ markerKey() {
+ if (this.#cachedMarkerCountKey === undefined) {
+ // TODO: Replace base item's MarkerBreakdown with a single-key class so this doesn't have to be calculated
+ // from scratch every time.
+ return this.#markers.reduce((acc, marker) => acc + MarkerBreakdown.deltaFromType(1, marker.markerType), 0);
+ }
+
+ return this.#cachedMarkerCountKey;
+ }
/**
* Returns whether a marker the user wants to add/edit is valid.
@@ -162,12 +195,12 @@ class MarkerTable {
* @param {MarkerData} newMarker The marker to add.
* @param {HTMLElement?} oldRow The temporary row used to create the marker, if any. */
addMarker(newMarker, oldRow) {
- if (this.#cachedMarkerCount !== undefined) {
+ if (this.#cachedMarkerCountKey !== undefined) {
// Assume that addMarker calls coming in when our table isn't initialized
// is coming from purge restores and just update the count/breakdown.
Log.tmi(`Got an addMarker call without an initialized table, updating cache count.`);
- ++this.#cachedMarkerCount;
- this.#parentRow.updateMarkerBreakdown(1 /*delta*/);
+ this.#cachedMarkerCountKey += MarkerBreakdown.deltaFromType(1, newMarker.markerType);
+ this.#parentRow.updateMarkerBreakdown();
return;
}
@@ -196,7 +229,7 @@ class MarkerTable {
this.#rows.splice(newIndex, 0, newRow);
this.#markers.splice(newIndex, 0, newMarker);
tableBody.insertBefore(newRow.row(), tableBody.children[newIndex]);
- this.#parentRow.updateMarkerBreakdown(1 /*delta*/);
+ this.#parentRow.updateMarkerBreakdown();
}
/**
@@ -229,7 +262,7 @@ class MarkerTable {
}
this.#markers.splice(newIndex, 0, updatedItem);
- this.#parentRow.updateMarkerBreakdown(0 /*delta*/); // This edit might update the purge status.
+ this.#parentRow.updateMarkerBreakdown(); // This edit might update the purge status.
return; // Same position, no rearranging needed.
}
@@ -243,7 +276,7 @@ class MarkerTable {
this.#rows.splice(newIndex, 0, this.#rows.splice(oldIndex, 1)[0]);
this.#markers.splice(newIndex, 0, updatedItem);
- this.#parentRow.updateMarkerBreakdown(0 /*delta*/); // This edit might update the purge status.
+ this.#parentRow.updateMarkerBreakdown(); // This edit might update the purge status.
}
/**
@@ -252,6 +285,14 @@ class MarkerTable {
* marker that's in {@linkcode this.markers}, but a standalone copy.
* @param {HTMLElement} [row=null] The HTML row for the deleted marker. */
deleteMarker(deletedMarker, row=null) {
+ if (this.#cachedMarkerCountKey !== undefined) {
+ // Assume that deleteMarker calls coming in when our table isn't initialized
+ // is coming from purge restores and just update the count/breakdown.
+ Log.tmi(`Got an addMarker call without an initialized table, updating cache count.`);
+ this.#cachedMarkerCountKey += MarkerBreakdown.deltaFromType(-1, deletedMarker.markerType);
+ this.#parentRow.updateMarkerBreakdown();
+ return;
+ }
const oldIndex = this.#markers.findIndex(x => x.id == deletedMarker.id);
let tableBody = this.#tbody();
if (this.#markers.length == 1) {
@@ -275,7 +316,7 @@ class MarkerTable {
tableBody.removeChild(row);
this.#markers.splice(oldIndex, 1);
this.#rows.splice(oldIndex, 1);
- this.#parentRow.updateMarkerBreakdown(-1 /*delta*/);
+ this.#parentRow.updateMarkerBreakdown();
}
/**
diff --git a/Client/Script/MarkerTableRow.js b/Client/Script/MarkerTableRow.js
index 4f0ee48..d99ce5e 100644
--- a/Client/Script/MarkerTableRow.js
+++ b/Client/Script/MarkerTableRow.js
@@ -20,7 +20,7 @@ class MarkerRow {
/**
* The media item row that owns this marker row.
- * @type {ResultRow} */
+ * @type {BaseItemResultRow} */
#parentRow;
/**
@@ -30,7 +30,7 @@ class MarkerRow {
/**
* Create a new base MarkerRow. This should not be instantiated on its own, only through its derived classes.
- * @param {ResultRow} parent The media item that owns this marker.
+ * @param {BaseItemResultRow} parent The media item that owns this marker.
* @param {boolean} isMovie Whether this marker is for a movie. */
constructor(parent) {
this.#parentRow = parent;
@@ -64,6 +64,12 @@ class MarkerRow {
/** Return the metadata id of the episode this marker belongs to. */
parent() { return this.#parentRow; }
+ /**
+ * The marker table this row belongs to can be cached across searches, but the
+ * result row will be different, so we have to update the parent.
+ * @param {BaseItemResultRow} parentRow */
+ setParent(parentRow) { this.#parentRow = parentRow; }
+
/** Returns the editor for this marker. */
editor() { return this.#editor; }
diff --git a/Client/Script/PlexClientState.js b/Client/Script/PlexClientState.js
index 6eb81fd..2051aaf 100644
--- a/Client/Script/PlexClientState.js
+++ b/Client/Script/PlexClientState.js
@@ -3,7 +3,7 @@ import { Log } from '../../Shared/ConsoleLog.js';
import { ShowData, SeasonData, SectionType, TopLevelData, MovieData } from '../../Shared/PlexTypes.js';
import { BulkActionType } from './BulkActionCommon.js';
-import { ClientEpisodeData } from './ClientDataExtensions.js';
+import { ClientEpisodeData, ClientMovieData } from './ClientDataExtensions.js';
import { PurgedMovieSection, PurgedSection } from './PurgedMarkerCache.js';
import { MovieResultRow, SeasonResultRow, ShowResultRow } from './ResultRow.js';
import SettingsManager from './ClientSettings.js';
@@ -26,14 +26,12 @@ class PlexClientState {
#activeSectionType = SectionType.TV;
/** @type {{[sectionId: number]: ShowMap|MovieMap}} */
#sections = {};
- /** @type {ShowData[]|MovieData[]} */
+ /** @type {ShowData[]|ClientMovieData[]} */
#activeSearch = [];
/** @type {ShowResultRow} */
#activeShow;
/** @type {SeasonResultRow} */
#activeSeason;
- /** @type {MovieResultRow} */
- #activeMovie;
/**@type {PlexClientState} */
static #clientState;
@@ -233,7 +231,7 @@ class PlexClientState {
let response;
try {
- response = await ServerCommand.getBreakdown(show.show().metadataId, seasons.length == 0 /*includeSeasons*/);
+ response = await ServerCommand.getBreakdown(show.show().metadataId, seasons.length !== 0 /*includeSeasons*/);
} catch (err) {
Log.warn(`Failed to update ("${errorMessage(err)}"), marker stats will be incorrect.`);
return;
@@ -246,14 +244,14 @@ class PlexClientState {
continue;
}
- seasonRow.season().markerBreakdown = newBreakdown;
+ seasonRow.season().setBreakdownFromRaw(newBreakdown);
seasonRow.updateMarkerBreakdown();
}
if (!response.showData) {
Log.warn(`PlexClientState::UpdateNonActiveBreakdown: Unable to find show breakdown data for ${show.show().metadataId}`);
} else {
- show.show().markerBreakdown = response.showData;
+ show.show().setBreakdownFromRaw(response.showData);
}
show.updateMarkerBreakdown();
@@ -265,22 +263,10 @@ class PlexClientState {
* @param {ClientEpisodeData} episode The episode a marker was added to/removed from.
* @param {number} delta 1 if a marker was added, -1 if removed. */
#updateBreakdownCacheInternal(episode, delta) {
- const newCount = episode.markerTable().markerCount();
- const oldCount = newCount - delta;
+ const newKey = episode.markerTable().markerKey();
+ const oldBucket = newKey - delta;
for (const media of [this.#activeShow, this.#activeSeason]) {
- const breakdown = media.mediaItem().markerBreakdown;
- if (!(oldCount in breakdown)) {
- Log.warn(`Old marker count bucket doesn't exist, that's not right!`);
- breakdown[oldCount] = 1;
- }
-
- --breakdown[oldCount];
- if (breakdown[oldCount] == 0) {
- delete breakdown[oldCount];
- }
-
- breakdown[newCount] ??= 0;
- ++breakdown[newCount];
+ media.mediaItem().markerBreakdown().delta(oldBucket, delta);
}
}
@@ -498,7 +484,10 @@ class PlexClientState {
let itemData;
switch (this.#activeSectionType) {
case SectionType.Movie:
- itemData = new MovieData().setFromJson(movieOrShow);
+ // TODO: investigate whether creating ClientMovieData
+ // directly causes any perf issues, or whether it's offset
+ // by not needing to do new ClientMovieData().setFromJson(...) within ResultRow
+ itemData = new ClientMovieData().setFromJson(movieOrShow);
break;
case SectionType.TV:
itemData = new ShowData().setFromJson(movieOrShow);
diff --git a/Client/Script/ResultRow.js b/Client/Script/ResultRow.js
index 8181ab1..db9d217 100644
--- a/Client/Script/ResultRow.js
+++ b/Client/Script/ResultRow.js
@@ -1,4 +1,4 @@
-import { $$, appendChildren, buildNode, clearEle, errorMessage, errorResponseOverlay, pad0, plural, ServerCommand } from './Common.js';
+import { $$, appendChildren, buildNode, errorMessage, errorResponseOverlay, pad0, plural, ServerCommand } from './Common.js';
import { Log } from '../../Shared/ConsoleLog.js';
import { MarkerData, PlexData, SeasonData, ShowData } from '../../Shared/PlexTypes.js';
@@ -10,13 +10,14 @@ import BulkAddOverlay from './BulkAddOverlay.js';
import BulkDeleteOverlay from './BulkDeleteOverlay.js';
import BulkShiftOverlay from './BulkShiftOverlay.js';
import ButtonCreator from './ButtonCreator.js';
-import { ClientEpisodeData, ClientMovieData } from './ClientDataExtensions.js';
+import { ClientEpisodeData, ClientMovieData, MediaItemWithMarkerTable } from './ClientDataExtensions.js';
import SettingsManager from './ClientSettings.js';
import PlexClientState from './PlexClientState.js';
import { PlexUI, UISection } from './PlexUI.js';
import PurgedMarkerManager from './PurgedMarkerManager.js';
import { PurgedSeason, PurgedShow } from './PurgedMarkerCache.js';
import ThemeColors from './ThemeColors.js';
+import MarkerBreakdown from '../../Shared/MarkerBreakdown.js';
/** @typedef {!import('../../Shared/PlexTypes.js').MarkerAction} MarkerAction */
/** @typedef {!import('../../Shared/PlexTypes.js').MarkerDataMap} MarkerDataMap */
@@ -148,7 +149,7 @@ class ResultRow {
episodeDisplay() {
const mediaItem = this.mediaItem();
const baseText = plural(mediaItem.episodeCount, 'Episode');
- if (!SettingsManager.Get().showExtendedMarkerInfo() || !mediaItem.markerBreakdown) {
+ if (!SettingsManager.Get().showExtendedMarkerInfo() || !mediaItem.markerBreakdown()) {
// The feature isn't enabled or we don't have a marker breakdown. The breakdown can be null if the
// user kept this application open while also adding episodes in PMS (which _really_ shouldn't be done).
return baseText;
@@ -157,10 +158,13 @@ class ResultRow {
let atLeastOne = 0;
// Tooltip should really handle more than plain text, but for now write the HTML itself to allow
// for slightly larger text than the default.
- let tooltipText = `${baseText}
`;
- const keys = Object.keys(mediaItem.markerBreakdown).sort((a, b) => parseInt(a) - parseInt(b));
- for (const key of keys) {
- const episodeCount = mediaItem.markerBreakdown[key];
+ let tooltipText = `${baseText}
`;
+ const breakdown = mediaItem.markerBreakdown();
+ const intros = breakdown.itemsWithIntros();
+ const credits = breakdown.itemsWithCredits();
+ tooltipText += `${intros} ${intros == 1 ? 'has' : 'have'} intros
`;
+ tooltipText += `${credits} ${credits == 1 ? 'has' : 'have'} credits
`;
+ for (const [key, episodeCount] of Object.entries(mediaItem.markerBreakdown().collapsedBuckets())) {
tooltipText += `${episodeCount} ${episodeCount == 1 ? 'has' : 'have'} ${plural(parseInt(key), 'marker')}
`;
if (key != 0) {
atLeastOne += episodeCount;
@@ -170,7 +174,7 @@ class ResultRow {
if (atLeastOne == 0) {
tooltipText = `${baseText}
None have markers.`;
} else {
- tooltipText += '';
+ tooltipText += this.hasPurgedMarkers() ? '
' : '';
}
const percent = (atLeastOne / mediaItem.episodeCount * 100).toFixed(2);
@@ -180,7 +184,7 @@ class ResultRow {
innerText.appendChild(purgeIcon());
const purgeCount = this.getPurgeCount();
const markerText = purgeCount == 1 ? 'marker' : 'markers';
- tooltipText += `
Found ${purgeCount} purged ${markerText}.
Click for details.`;
+ tooltipText += `Found ${purgeCount} purged ${markerText}.
Click for details.`;
}
let mainText = buildNode('span', { class : 'episodeDisplayText'}, innerText);
@@ -638,10 +642,35 @@ class SeasonResultRow extends ResultRow {
}
}
+/**
+ * Class with functionality shared between "base" media types, i.e. movies and episodes.
+ */
+class BaseItemResultRow extends ResultRow {
+ /** Current MarkerBreakdown key. See MarkerCacheManager.js's BaseItemNode */
+ #markerCountKey = 0;
+
+ /**
+ * @param {MediaItemWithMarkerTable} mediaItem
+ * @param {string} [className] */
+ constructor(mediaItem, className) {
+ super(mediaItem, className);
+ if (mediaItem && mediaItem.markerBreakdown()) {
+ // Episodes are loaded differently from movies. It's only expected that movies have a valid value
+ // here. Episodes set this when creating the marker table for the first time.
+ Log.assert(mediaItem instanceof ClientMovieData, 'mediaItem instanceof ClientMovieData');
+ this.#markerCountKey = MarkerBreakdown.keyFromMarkerCount(mediaItem.markerBreakdown().totalIntros(), mediaItem.markerBreakdown().totalCredits());
+ }
+ }
+
+ currentKey() { return this.#markerCountKey; }
+ /** @param {number} key */
+ setCurrentKey(key) { this.#markerCountKey = key; }
+}
+
/**
* A result row for a single episode of a show.
*/
-class EpisodeResultRow extends ResultRow {
+class EpisodeResultRow extends BaseItemResultRow {
/**
* The parent {@linkcode SeasonResultRow}, used to communicate that marker tables of all
* episodes in the season need to be shown/hidden.
@@ -754,39 +783,33 @@ class EpisodeResultRow extends ResultRow {
}
/**
- * Updates the marker statistics both in the UI and the client state.
- * @param {number} delta 1 if a marker was added to this episode, -1 if one was removed. */
- updateMarkerBreakdown(delta) {
+ * Updates the marker statistics both in the UI and the client state. */
+ updateMarkerBreakdown() {
// Don't bother updating in-place, just recreate and replace.
const newNode = this.#buildMarkerText();
const oldNode = $$('.episodeDisplayText', this.html());
oldNode.replaceWith(newNode);
+ const newKey = this.episode().markerTable().markerKey();
+ const delta = newKey - this.currentKey();
if (SettingsManager.Get().showExtendedMarkerInfo()) {
PlexClientState.GetState().updateBreakdownCache(this.episode(), delta);
}
+
+ this.setCurrentKey(newKey);
}
}
-class MovieResultRow extends ResultRow {
+class MovieResultRow extends BaseItemResultRow {
/** @type {boolean} */
#markersGrabbed = false;
/**
- * TODO: A better system here. This is used when extended marker stats
- * are disabled to signal back to the original item that we know how many
- * markers this thing actually has, since this is the one that gets reused
- * across searches.
- * @type {MovieData} */
- #plainData;
-
- /**
- * @param {MovieData} mediaItem */
+ * @param {ClientMovieData} mediaItem */
constructor(mediaItem) {
- let clientItem = new ClientMovieData().setFromJson(mediaItem);
- super(clientItem, 'topLevelResult movieResultRow');
- this.#plainData = mediaItem;
+ super(mediaItem, 'topLevelResult movieResultRow');
+ this.#markersGrabbed = this.movie().markerTable()?.hasRealData();
}
/**
* Return the underlying episode data associated with this result row.
@@ -924,7 +947,6 @@ class MovieResultRow extends ResultRow {
markerData[mov.metadataId].sort((a, b) => a.start - b.start);
if (mov.realMarkerCount == -1) {
mov.realMarkerCount = markerData[mov.metadataId].length;
- this.#plainData.realMarkerCount = 0; // The initialize call below ensures the right delta.
}
if (SettingsManager.Get().backupEnabled()) {
@@ -969,39 +991,29 @@ class MovieResultRow extends ResultRow {
}
/**
- * Updates the marker statistics both in the UI and the client state.
- * @param {number} delta 1 if a marker was added to this movie, -1 if one was removed. Unused, but mirrors ResultRow signature. */
- updateMarkerBreakdown(delta) {
+ * Updates the marker statistics both in the UI and the client state. */
+ updateMarkerBreakdown() {
// Don't bother updating in-place, just recreate and replace.
const newNode = this.#buildMarkerText();
const oldNode = $$('.episodeDisplayText', this.html());
oldNode.replaceWith(newNode);
+ const newKey = this.movie().markerTable().markerKey();
+ const oldKey = this.currentKey();
// Note: No need to propagate changes up like for episodes, since
// we're already at the top of the chain. The section-wide
// marker chart queries the server directly every time.
- const newCount = this.movie().markerTable().markerCount();
- const oldCount = newCount - delta;
- const breakdown = this.movie().markerBreakdown;
+ const breakdown = this.movie().markerBreakdown();
if (!breakdown) {
// Extended stats not enabled.
- this.#plainData.realMarkerCount += delta;
+ this.movie().realMarkerCount = this.movie().markerTable().markerCount();
return;
}
- if (!(oldCount in breakdown)) {
- Log.warn(`Old marker count bucket doesn't exist, that's not right!`);
- breakdown[oldCount] = 1;
- }
-
- --breakdown[oldCount];
- if (breakdown[oldCount] == 0) {
- delete breakdown[oldCount];
- }
+ breakdown.delta(oldKey, newKey - oldKey);
+ this.setCurrentKey(newKey);
- breakdown[newCount] ??= 0;
- ++breakdown[newCount];
}
}
-export { ResultRow, ShowResultRow, SeasonResultRow, EpisodeResultRow, MovieResultRow }
+export { ResultRow, ShowResultRow, SeasonResultRow, EpisodeResultRow, MovieResultRow, BaseItemResultRow }
diff --git a/Client/Script/inc/Chart.js b/Client/Script/inc/Chart.js
index 2580a29..91d0bf8 100644
--- a/Client/Script/inc/Chart.js
+++ b/Client/Script/inc/Chart.js
@@ -125,6 +125,7 @@ let Chart = new function()
sortData(data);
let total = data.points.reduce((acc, cur) => acc + cur.value, 0);
+ const singlePoint = data.points.length === 1;
let r = data.radius;
let hasTitle = data.title && !data.noTitle;
@@ -154,8 +155,9 @@ let Chart = new function()
{
sliceColor = colors[colorIndex++ % colors.length];
}
- let slice = buildPieSlice(d, sliceColor);
+ // For a single point, ignore the whole path we created above and just make a circle
+ let slice = singlePoint ? buildPieCircle(r, titleOffset, sliceColor) : buildPieSlice(d, sliceColor);
let label = buildPieTooltip(point, total, data.labelOptions);
if (label.length != 0)
{
@@ -338,6 +340,33 @@ let Chart = new function()
});
};
+ /**
+ * Creates a circle representing the one and only data point for a pie chart.
+ * @param {number} radius Radius of the circle
+ * @param {number} yOffset Additional y-offset for the circle
+ * @param {string} fill The fill color as a hex string
+ * @returns A SVG Circle for a pie chart with a single data point.
+ */
+ let buildPieCircle = function(radius, yOffset, fill)
+ {
+ return buildNodeNS("http://www.w3.org/2000/svg",
+ "circle",
+ {
+ r : radius,
+ cx : radius,
+ cy : radius + yOffset,
+ fill : fill,
+ stroke : "#616161",
+ "pointer-events" : "all",
+ xmlns : "http://www.w3.org/2000/svg"
+ },
+ 0,
+ {
+ mouseenter : highlightPieSlice,
+ mouseleave : function() { this.setAttribute("stroke", "#616161"); }
+ })
+ }
+
/**
* Builds tooltip text for a point on the chart.
* @param {ChartDataPoint} point The `{ value, label }` data for the point.
diff --git a/Client/Style/inc/Tooltip.css b/Client/Style/inc/Tooltip.css
index 0859afa..0649ac4 100644
--- a/Client/Style/inc/Tooltip.css
+++ b/Client/Style/inc/Tooltip.css
@@ -14,3 +14,9 @@
.largerTooltip {
font-size: 12pt;
}
+
+#tooltip hr {
+ margin-top: 3px;
+ margin-bottom: 3px;
+ opacity: 0.8;
+}
\ No newline at end of file
diff --git a/Client/Style/style.css b/Client/Style/style.css
index 9cfe97a..f7d6611 100644
--- a/Client/Style/style.css
+++ b/Client/Style/style.css
@@ -75,7 +75,7 @@ input, select {
text-align: center;
}
-#libraries,
+.fancySelect,
#search {
padding: 5px;
border-radius: 5px;
@@ -84,7 +84,7 @@ input, select {
font-size: 14pt;
}
-#libraries {
+.fancySelect {
min-width: 350px;
}
diff --git a/Client/Style/themeDark.css b/Client/Style/themeDark.css
index dbaf18f..5d3dc95 100644
--- a/Client/Style/themeDark.css
+++ b/Client/Style/themeDark.css
@@ -28,15 +28,15 @@ a:visited {
color: #6191c1;
}
-#libraries option {
+.fancySelect option {
background-color: #212121;
}
-#libraries option:hover {
+.fancySelect option:hover {
background-color: #414141;
}
-#libraries:hover,
+.fancySelect:hover,
#search:hover,
.inlineButton:hover {
border-color: #909090;
diff --git a/Client/Style/themeLight.css b/Client/Style/themeLight.css
index 6e54f88..c2c9572 100644
--- a/Client/Style/themeLight.css
+++ b/Client/Style/themeLight.css
@@ -21,15 +21,15 @@ hr {
border-color: #444;
}
-#libraries option {
+.fancySelect option {
background-color: #ffcfaa;
}
-#libraries option:hover {
+.fancySelect option:hover {
background-color: #ff8f88;
}
-#libraries:hover,
+.fancySelect:hover,
#search:hover,
.inlineButton:hover {
border-color: #000;
diff --git a/Server/Commands/QueryCommands.js b/Server/Commands/QueryCommands.js
index fd48d96..190f88d 100644
--- a/Server/Commands/QueryCommands.js
+++ b/Server/Commands/QueryCommands.js
@@ -1,5 +1,5 @@
import { Log } from "../../Shared/ConsoleLog.js";
-import { EpisodeData, MarkerData, MovieData, SeasonData, SectionType, ShowData } from "../../Shared/PlexTypes.js";
+import { EpisodeData, MarkerData, MarkerType, MovieData, SeasonData, SectionType, ShowData } from "../../Shared/PlexTypes.js";
import LegacyMarkerBreakdown from "../LegacyMarkerBreakdown.js";
import { MarkerCache } from "../MarkerCacheManager.js";
@@ -192,18 +192,21 @@ class QueryCommands {
let buckets = {};
Log.verbose(`Parsing ${rows.length} tags`);
- let idCur = -1;
+ let idCur = rows.length > 0 ? rows[0].parent_id : -1;
let countCur = 0;
+ // See MarkerBreakdown.js
+ const bucketDelta = (markerType) => markerType == MarkerType.Intro ? 1 : (1 << 16);
for (const row of rows) {
if (row.parent_id == idCur) {
if (row.tag_id == PlexQueries.markerTagId()) {
- ++countCur;
+ // See MarkerBreakdown.js
+ countCur += bucketDelta(row.marker_type);
}
} else {
buckets[countCur] ??= 0;
++buckets[countCur];
idCur = row.parent_id;
- countCur = row.tag_id == PlexQueries.markerTagId() ? 1 : 0;
+ countCur = row.tag_id == PlexQueries.markerTagId() ? bucketDelta(row.marker_type) : 0;
}
}
diff --git a/Server/MarkerCacheManager.js b/Server/MarkerCacheManager.js
index 1a1b35c..881bed0 100644
--- a/Server/MarkerCacheManager.js
+++ b/Server/MarkerCacheManager.js
@@ -1,4 +1,5 @@
import { Log } from '../Shared/ConsoleLog.js';
+import MarkerBreakdown from '../Shared/MarkerBreakdown.js';
import DatabaseWrapper from './DatabaseWrapper.js';
/** @typedef {!import('./PlexQueryManager').RawMarkerData} RawMarkerData */
@@ -12,6 +13,7 @@ import DatabaseWrapper from './DatabaseWrapper.js';
* @typedef {number} MarkerId
* @typedef {{
* id : number,
+ * marker_type : string,
* parent_id : number,
* season_id : number,
* show_id : number,
@@ -22,68 +24,27 @@ import DatabaseWrapper from './DatabaseWrapper.js';
*/
/**
- * Manages marker statistics at an arbitrary level (section/series/season/episode)
+ * Extension of MarkerBreakdown to handle the parent hierarchy that the client-side breakdown doesn't have.
*/
-class MarkerBreakdown {
- /** @type {MarkerBreakdownMap} */
- #counts = { 0 : 0 };
+class ServerMarkerBreakdown extends MarkerBreakdown {
/** @type {MarkerNodeBase} */
#parent;
/** @param {MarkerNodeBase} parent */
constructor(parent=null) {
+ super();
this.#parent = parent;
}
- /** @returns {MarkerBreakdownMap} */
- data() {
- // Create a copy by stringifying/serializing to prevent underlying data from being overwritten.
- this.#minify();
- return JSON.parse(JSON.stringify(this.#counts));
- }
-
- /** Increase the marker count for an episode that previously had `oldCount` markers.
- * @param {number} oldCount */
- add(oldCount) {
- this.delta(oldCount, 1);
- }
-
- /** Decrease the marker count for an episode that previously had `oldCount` markers.
- * @param {number} oldCount */
- remove(oldCount) {
- this.delta(oldCount, -1);
- }
-
- /** Adjust the marker count for an episode that previously had `oldCount` markers
- * @param {number} oldCount
- * @param {number} delta 1 if a marker was added, -1 if one was deleted. */
delta(oldCount, delta) {
- this.#counts[oldCount + delta] ??= 0;
- --this.#counts[oldCount];
- ++this.#counts[oldCount + delta];
- if (this.#parent) {
- this.#parent.markerBreakdown.delta(oldCount, delta);
- }
+ super.delta(oldCount, delta);
+ this.#parent?.markerBreakdown.delta(oldCount, delta);
}
- /**
- * Handles a new base item (movie/episode) in the database.
- * Adds to the 'items with 0 markers' bucket for the media item and all parent categories. */
initBase() {
- ++this.#counts[0];
+ super.initBase();
this.#parent?.markerBreakdown.initBase();
}
-
- /** Removes any marker counts that have no episodes in `#counts` */
- #minify() {
- // Remove episode counts that have no episodes.
- const keys = Object.keys(this.#counts);
- for (const key of keys) {
- if (this.#counts[key] == 0) {
- delete this.#counts[key];
- }
- }
- }
}
/** Base class for a node in the {@linkcode MarkerCacheManager}'s hierarchical data. */
@@ -91,7 +52,7 @@ class MarkerNodeBase {
markerBreakdown;
/** @param {MarkerNodeBase} parent */
constructor(parent=null) {
- this.markerBreakdown = new MarkerBreakdown(parent);
+ this.markerBreakdown = new ServerMarkerBreakdown(parent);
}
}
@@ -106,12 +67,42 @@ class MarkerSectionNode extends MarkerNodeBase {
/** Represents the lowest-level media node, i.e. a node that can have markers added to it. */
class BaseItemNode extends MarkerNodeBase {
+
/** @type {MarkerId[]} */
markers = [];
+ /** The current bucket key for this breakdown, which indicates the number of both intros and credits. */
+ #currentKey = 0;
constructor(parent) {
super(parent);
this.markerBreakdown.initBase();
}
+
+ /**
+ * Add the given marker to the breakdown cache.
+ * @param {MarkerQueryResult} markerData */
+ add(markerData) {
+ this.#deltaBase(markerData, 1);
+ }
+
+ /**
+ * Remove the given marker to the breakdown cache.
+ * @param {number} oldCount */
+ remove(markerData) {
+ this.#deltaBase(markerData, -1);
+ }
+
+ /**
+ * Signals the addition/removal of a marker.
+ * @param {MarkerQueryResult} markerData
+ * @param {number} multiplier 1 if we're adding a marker, -1 if we're removing one */
+ #deltaBase(markerData, multiplier) {
+ // TODO: temporary. Make sure that base items only have a single "active" bucket, it doesn't
+ // make sense for a single episode/movie to have multiple buckets.
+ Log.assert(this.markerBreakdown.buckets() == 1);
+ const deltaReal = MarkerBreakdown.deltaFromType(multiplier, markerData.marker_type);
+ this.markerBreakdown.delta(this.#currentKey, deltaReal);
+ this.#currentKey += deltaReal;
+ }
}
/** Representation of a movie in the marker cache. */
@@ -255,7 +246,7 @@ class MarkerCacheManager {
delete this.#allMarkers[markerId];
const baseItem = this.#drillDown(markerData);
- baseItem.markerBreakdown.remove(baseItem.markers.length);
+ baseItem.remove(markerData);
baseItem.markers = baseItem.markers.filter(marker => marker != markerId);
}
@@ -430,7 +421,7 @@ class MarkerCacheManager {
}
if (isMarker) {
- base.markerBreakdown.add(base.markers.length);
+ base.add(tag);
base.markers.push(tag.id);
if (tag.id in this.#allMarkers) {
@@ -472,6 +463,7 @@ class MarkerCacheManager {
static #episodeMarkerQueryBase = `
SELECT
marker.id AS id,
+ marker.text AS marker_type,
base.id AS parent_id,
season.id AS season_id,
season.parent_id AS show_id,
@@ -484,7 +476,7 @@ WHERE base.metadata_type=4
`;
/** Query to grab all intro/credits markers on the server. */
- static #markerOnlyQuery = `SELECT id, metadata_item_id AS parent_id FROM taggings WHERE tag_id=?;`;
+ static #markerOnlyQuery = `SELECT id, text AS marker_type, metadata_item_id AS parent_id FROM taggings WHERE tag_id=?;`;
/** Query to grab all episodes and movies from the database. For episodes, also include season/show id (replaced with -1 for movies) */
static #mediaOnlyQuery = `
diff --git a/Server/PlexQueryManager.js b/Server/PlexQueryManager.js
index 8411528..c2087f1 100644
--- a/Server/PlexQueryManager.js
+++ b/Server/PlexQueryManager.js
@@ -1114,14 +1114,14 @@ ORDER BY e.\`index\` ASC;`;
* Fields returned: `parent_id`, `tag_id`
* TODO: Movies
* @param {number} sectionId
- * @returns {Promise<{parent_id: number, tag_id: number}[]>} */
+ * @returns {Promise<{parent_id: number, tag_id: number, marker_type: string}[]>} */
async markerStatsForSection(sectionId) {
const baseType = await this.#baseItemTypeFromSection(sectionId);
// Note that the query below that grabs _all_ tags for an item and discarding
// those that aren't markers is faster than doing an outer join on a
// temporary taggings table that only includes markers
const query = `
- SELECT b.id AS parent_id, m.tag_id AS tag_id FROM metadata_items b
+ SELECT b.id AS parent_id, m.tag_id AS tag_id, m.text AS marker_type FROM metadata_items b
LEFT JOIN taggings m ON b.id=m.metadata_item_id
WHERE b.library_section_id=? AND b.metadata_type=?
ORDER BY b.id ASC;`;
diff --git a/Shared/MarkerBreakdown.js b/Shared/MarkerBreakdown.js
new file mode 100644
index 0000000..76ce113
--- /dev/null
+++ b/Shared/MarkerBreakdown.js
@@ -0,0 +1,172 @@
+import { MarkerType } from './PlexTypes.js';
+
+const IntroMask = 0x0000FFFF;
+const CreditsShift = 16;
+
+/** @typedef {!import('./PlexTypes').MarkerBreakdownMap} MarkerBreakdownMap */
+
+/**
+ * Manages marker statistics at an arbitrary level (section/series/season/episode)
+ */
+class MarkerBreakdown {
+ /** @type {MarkerBreakdownMap} */
+ #counts = { 0 : 0 };
+
+ constructor() {}
+
+ static deltaFromType(delta, markerType) {
+ switch (markerType) {
+ case MarkerType.Intro:
+ return delta;
+ case MarkerType.Credits:
+ return delta << CreditsShift;
+ default:
+ throw new Error(`Invalid marker type ${markerType}`);
+ }
+ }
+
+ static markerCountFromKey(key) {
+ return (key >> CreditsShift) + (key & IntroMask);
+ }
+
+ static keyFromMarkerCount(intros, credits) {
+ return MarkerBreakdown.deltaFromType(intros, MarkerType.Intro) + MarkerBreakdown.deltaFromType(credits, MarkerType.Credits);
+ }
+
+ /**
+ * Initialize a marker breakdown using a raw count dictionary.
+ * @param {MarkerBreakdownMap} rawMarkerBreakdown */
+ initFromRawBreakdown(rawMarkerBreakdown) {
+ Log.assert(
+ this.buckets() == 0 && this.#counts[0] === 0,
+ `Trying to initialize a marker breakdown from raw data, but we've already set some data!`);
+ this.#counts = rawMarkerBreakdown;
+ return this;
+ }
+
+ buckets() { return Object.keys(this.#counts).filter(c => this.#counts[c] != 0).length; }
+
+ /**
+ * @returns {MarkerBreakdownMap} */
+ collapsedBuckets() {
+ const collapsed = {};
+ let minify = false;
+ for (const [key, value] of Object.entries(this.#counts)) {
+ if (value === 0) {
+ minify = true;
+ continue;
+ }
+
+ const realKey = this.#ic(key) + this.#cc(key);
+ collapsed[realKey] ??= 0;
+ collapsed[realKey] += value;
+ }
+
+ if (minify) {
+ this.#minify();
+ }
+
+ return collapsed;
+ }
+
+ /**
+ * @returns {MarkerBreakdownMap} */
+ introBuckets() {
+ const collapsed = {};
+ for (const [key, value] of Object.entries(this.#counts)) {
+ const introKey = this.#ic(key);
+ collapsed[introKey] ??= 0;
+ collapsed[introKey] += value;
+ }
+
+ return collapsed;
+ }
+
+ /**
+ * @returns {MarkerBreakdownMap} */
+ creditsBuckets() {
+ const collapsed = {};
+ for (const [key, value] of Object.entries(this.#counts)) {
+ const creditsKey = this.#cc(key);
+ collapsed[creditsKey] ??= 0;
+ collapsed[creditsKey] += value;
+ }
+
+ return collapsed;
+ }
+
+ #ic(v) { return v & IntroMask; }
+ #cc(v) { return v >> CreditsShift; }
+
+ /**
+ * Return the total count of markers in this breakdown. */
+ totalMarkers() {
+ return Object.entries(this.#counts).reduce((acc, kv) => acc + ((this.#cc(kv[0]) + this.#ic(kv[0])) * kv[1]), 0)
+ }
+
+ /**
+ * Return the total number of intro markers in this breakdown. */
+ totalIntros() {
+ return Object.entries(this.#counts).reduce((acc, kv) => acc + (this.#ic(kv[0]) * kv[1]), 0);
+ }
+
+ /**
+ * The total number of credits markers in this breakdown. */
+ totalCredits() {
+ return Object.entries(this.#counts).reduce((acc, kv) => acc + (this.#cc(kv[0]) * kv[1]), 0);
+ }
+
+ /**
+ * The total number of items represented in this breakdown. */
+ totalItems() {
+ return Object.values(this.#counts).reduce((acc, v) => acc + v, 0);
+ }
+
+ /**
+ * The total number of items that have an intro marker in this breakdown. */
+ itemsWithIntros() {
+ return Object.entries(this.#counts).reduce((acc, kv) => acc + (this.#ic(kv[0]) > 0 ? kv[1] : 0), 0);
+ }
+
+ /**
+ * The total number of items that have a credits marker in this breakdown. */
+ itemsWithCredits() {
+ return Object.entries(this.#counts).reduce((acc, kv) => acc + (this.#cc(kv[0]) > 0 ? kv[1] : 0), 0);
+ }
+
+ /** @returns {MarkerBreakdownMap} */
+ data() {
+ // Create a copy by stringifying/serializing to prevent underlying data from being overwritten.
+ this.#minify();
+ return JSON.parse(JSON.stringify(this.#counts));
+ }
+
+ /** Adjust the marker count for an episode that previously had `oldCount` markers
+ * @param {number} oldBucket The old bucket
+ * @param {number} delta positive if a marker was added, negative if one was deleted. */
+ delta(oldBucket, delta) {
+ this.#counts[oldBucket + delta] ??= 0;
+ --this.#counts[oldBucket];
+ ++this.#counts[oldBucket + delta];
+ }
+
+ /**
+ * Handles a new base item (movie/episode) in the database.
+ * Adds to the 'items with 0 markers' bucket for the media item and all parent categories. */
+ initBase() {
+ ++this.#counts[0];
+ }
+
+ /** Removes any marker counts that have no episodes in `#counts` */
+ #minify() {
+ // Remove episode counts that have no episodes.
+ const keys = Object.keys(this.#counts);
+ for (const key of keys) {
+ if (this.#counts[key] == 0) {
+ delete this.#counts[key];
+ }
+ }
+ }
+}
+
+export default MarkerBreakdown;
diff --git a/Shared/PlexTypes.js b/Shared/PlexTypes.js
index 24c9d97..70d200b 100644
--- a/Shared/PlexTypes.js
+++ b/Shared/PlexTypes.js
@@ -2,6 +2,8 @@
* Contains classes that represent show, season, episode, and marker data.
*/
+import MarkerBreakdown from './MarkerBreakdown.js';
+
/**
* @typedef {!import('../Server/MarkerCacheManager').MarkerBreakdownMap} MarkerBreakdownMap
* @typedef {{id: number, type: number, name: string}} LibrarySection A library in the Plex database.
@@ -103,9 +105,15 @@ class PlexData {
metadataId;
/**
- * The breakdown of how many episodes have X markers.
+ * The breakdown of how many episodes have X markers, as a raw dictionary of values.
* @type {MarkerBreakdownMap} */
- markerBreakdown;
+ rawMarkerBreakdown;
+
+ /**
+ * The "real" marker breakdown class based on the raw marker breakdown.
+ * Should only be used client-side.
+ * @type {MarkerBreakdown} */
+ #markerBreakdown;
/**
* @param {PlexDataBaseData} [data]
@@ -114,7 +122,7 @@ class PlexData {
{
if (data) {
this.metadataId = data.metadataId;
- this.markerBreakdown = data.markerBreakdown;
+ this.rawMarkerBreakdown = data.markerBreakdown;
}
}
@@ -124,8 +132,30 @@ class PlexData {
* @returns itself. */
setFromJson(json) {
Object.assign(this, json);
+ if (this.rawMarkerBreakdown) {
+ this.#markerBreakdown = new MarkerBreakdown().initFromRawBreakdown(this.rawMarkerBreakdown);
+ // We don't want to reference rawMarkerBreakdown after this.
+ this.rawMarkerBreakdown = undefined;
+ }
+
return this;
}
+
+ /**
+ * @param {MarkerBreakdownMap} */
+ setBreakdownFromRaw(rawBreakdown) {
+ this.#markerBreakdown = new MarkerBreakdown().initFromRawBreakdown(rawBreakdown);
+ }
+
+ /**
+ * Overwrites the current marker breakdown with the new one.
+ * @param {MarkerBreakdown} newBreakdown */
+ setBreakdown(newBreakdown) {
+ this.#markerBreakdown = newBreakdown;
+ }
+
+ /** @returns {MarkerBreakdown} */
+ markerBreakdown() { return this.#markerBreakdown; }
}
/**
diff --git a/index.html b/index.html
index cee1d0f..ffe5285 100644
--- a/index.html
+++ b/index.html
@@ -28,7 +28,7 @@