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 @@

Marker Editor

-
+