diff --git a/CHANGELOG.md b/CHANGELOG.md index b101b81..735c8c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v2.2.0] - 2024-07-27 + +### Added + +- Added try-catch error handling to `main()` +- Created `getPlaylistMetadataElement` function + - The playlist metadata element appears to have a different identifier + depending on if the user has YouTube premium or not + - This function will take that into account and use the appropriate selector + to find the metadata element +- Added `youtubePremium` variant to list of `playlistMetadata` element selectors +- Added translations for `fr` locale +- Implemented sorting by view count & upload date for `fr` locale +- Added tests for the `fr` locale parsers + +### Fixed + +- Fixed extension not loading for youtube premium layouts + ## [v2.1.4] - 2024-07-26 ### Removed diff --git a/package.json b/package.json index d3b31b2..2d63ae1 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "description": "An extension to calculate & display the total duration of a youtube playlist.", "author": "nrednav", "private": true, - "version": "2.1.4", + "version": "2.2.0", "type": "module", "engines": { "node": ">=20", @@ -13,6 +13,7 @@ "dev": "vite", "build:chrome": "pnpm run clean && vite build --mode chrome", "build:firefox": "pnpm run clean && vite build --mode firefox", + "test": "node --test", "clean": "pnpm exec del dist/", "watch": "pnpm run clean && vite build --watch --mode development", "lint": "eslint .", diff --git a/public/_locales/fr/messages.json b/public/_locales/fr/messages.json new file mode 100644 index 0000000..7ef92d8 --- /dev/null +++ b/public/_locales/fr/messages.json @@ -0,0 +1,98 @@ +{ + "loaderMessage": { + "message": "Calcul en cours...", + "description": "Text to display when the extension is loading" + }, + "videoTitle_private": { + "message": "[Vidéo privée]", + "description": "Title displayed by YouTube for private videos" + }, + "videoTitle_deleted": { + "message": "[Vidéo supprimée]", + "description": "Title displayed by YouTube for deleted videos" + }, + "videoTitle_unavailable_v1": { + "message": "[Indisponible]", + "description": "First variation of title displayed by YouTube for unavailable videos" + }, + "videoTitle_unavailable_v2": { + "message": "[Vidéo indisponible]", + "description": "Second variation of title displayed by YouTube for unavailable videos" + }, + "videoTitle_restricted": { + "message": "[Vidéo restreinte]", + "description": "Title displayed by YouTube for restricted videos" + }, + "videoTitle_ageRestricted": { + "message": "[Limite d'âge]", + "description": "Title displayed by YouTube for age-restricted videos" + }, + "problemEncountered_paragraphOne": { + "message": "Un problème est survenu.", + "description": "Text to display in first paragraph when a problem has been encountered" + }, + "problemEncountered_paragraphTwo": { + "message": "Veuillez recharger cette page pour recalculer la durée de la playlist.", + "description": "Text to display in second paragraph when a problem has been encountered" + }, + "playlistSummary_totalDuration": { + "message": "Durée totale:", + "description": "Text to display as label for the playlist duration" + }, + "playlistSummary_videosCounted": { + "message": "Vidéos comptées:", + "description": "Text to display as label for the videos counted" + }, + "playlistSummary_videosNotCounted": { + "message": "Vidéos non comptées:", + "description": "Text to display as label for the videos not counted" + }, + "playlistSummary_tooltip": { + "message": "Faites défiler vers le bas pour compter plus de vidéos", + "description": "Text to display within tooltip" + }, + "sortDropdown_label": { + "message": "Trier par:", + "description": "Text to display as label for the sort dropdown" + }, + "sortType_index_label_asc": { + "message": "Index (Croissant)", + "description": "Text to display for the ascending 'sort by index' option" + }, + "sortType_index_label_desc": { + "message": "Index (Décroissant)", + "description": "Text to display for the descending 'sort by index' option" + }, + "sortType_duration_label_asc": { + "message": "Durée (Plus courte)", + "description": "Text to display for the ascending 'sort by duration' option" + }, + "sortType_duration_label_desc": { + "message": "Durée (Plus longue)", + "description": "Text to display for the descending 'sort by duration' option" + }, + "sortType_channelName_label_asc": { + "message": "Nom de chaîne (A-Z)", + "description": "Text to display for the ascending 'sort by channel name' option" + }, + "sortType_channelName_label_desc": { + "message": "Nom de chaîne (Z-A)", + "description": "Text to display for the descending 'sort by channel name' option" + }, + "sortType_views_label_asc": { + "message": "Vues (Moins vues)", + "description": "Text to display for the ascending 'sort by views' option" + }, + "sortType_views_label_desc": { + "message": "Vues (Plus vues)", + "description": "Text to display for the descending 'sort by views' option" + }, + "sortType_uploadDate_label_asc": { + "message": "Date de mise en ligne (Plus récente)", + "description": "Text to display for the ascending 'sort by upload date' option" + }, + "sortType_uploadDate_label_desc": { + "message": "Date de mise en ligne (Plus ancienne)", + "description": "Text to display for the descending 'sort by upload date' option" + } +} diff --git a/src/main.js b/src/main.js index dadaca7..99e28d1 100644 --- a/src/main.js +++ b/src/main.js @@ -8,8 +8,12 @@ import { import "./main.css"; const main = () => { - setupPage(); - checkPlaylistReady(); + try { + setupPage(); + checkPlaylistReady(); + } catch (error) { + logger.error(error.message); + } }; const checkPlaylistReady = () => { @@ -77,7 +81,10 @@ const checkPlaylistReady = () => { const displayLoader = () => { const playlistSummaryElement = getPlaylistSummaryElement(); - if (!playlistSummaryElement) return; + + if (!playlistSummaryElement) { + return; + } const loaderElement = document.createElement("div"); loaderElement.id = "ytpdc-loader"; @@ -171,6 +178,7 @@ const isElementVisible = (element) => { const getPlaylistSummaryElement = () => { const selector = elementSelectors.playlistSummary[isNewDesign() ? "new" : "old"]; + return document.querySelector(selector); }; @@ -199,9 +207,11 @@ const countUnavailableTimestamps = () => { **/ const getVideos = () => { const playlistElement = document.querySelector(elementSelectors.playlist); + if (!playlistElement) return []; const videos = playlistElement.getElementsByTagName(elementSelectors.video); + return [...videos]; }; @@ -260,6 +270,7 @@ const processPlaylist = () => { Array.isArray(timestamps) && timestamps.length > 0 ? timestamps.reduce((a, b) => a + b) : 0; + const playlistDuration = convertSecondsToTimestamp(totalDurationInSeconds); addPlaylistSummaryToPage({ timestamps, playlistDuration, playlistObserver }); @@ -278,10 +289,15 @@ const setupPlaylistObserver = () => { if (window.ytpdc.playlistObserver) return window.ytpdc.playlistObserver; const playlistElement = document.querySelector(elementSelectors.playlist); - if (!playlistElement) return null; + + if (!playlistElement) { + return null; + } const playlistObserver = new MutationObserver(onPlaylistMutated); + playlistObserver.observe(playlistElement, { childList: true }); + window.ytpdc.playlistObserver = playlistObserver; return { @@ -309,12 +325,13 @@ const onPlaylistMutated = (mutationList, observer) => { chrome.i18n.getMessage("problemEncountered_paragraphOne"), chrome.i18n.getMessage("problemEncountered_paragraphTwo") ]); + observer.disconnect(); + return; } // No problem encountered, continue processing mutation - const removedVideo = mutation.removedNodes[0]; // If the playlist was sorted, YouTube removes the wrong video from the @@ -332,8 +349,11 @@ const onPlaylistMutated = (mutationList, observer) => { } observer.disconnect(); + window.ytpdc.lastVideoInteractedWith.remove(); + observer.observe(playlistElement, { childList: true }); + main(); } else { main(); @@ -363,7 +383,10 @@ const shouldRequestPageReload = (mutation) => { */ const displayMessages = (messages) => { const playlistSummaryElement = getPlaylistSummaryElement(); - if (!playlistSummaryElement) return; + + if (!playlistSummaryElement) { + return; + } const containerElement = document.createElement("div"); containerElement.id = "messages-container"; @@ -396,14 +419,20 @@ const addPlaylistSummaryToPage = ({ if (existingPlaylistSummaryElement) { existingPlaylistSummaryElement.replaceWith(playlistSummaryElement); } else { - const metadataElement = document.querySelector( - elementSelectors.playlistMetadata[isNewDesign() ? "new" : "old"] - ); - if (!metadataElement) return null; + const playlistMetadataElement = getPlaylistMetadataElement(); + + if (!playlistMetadataElement) { + throw new Error( + [ + "Cannot add playlist summary to page", + "Reason = Cannot find playlist metadata element in document" + ].join(", ") + ); + } - metadataElement.parentElement.insertBefore( + playlistMetadataElement.parentElement.insertBefore( playlistSummaryElement, - metadataElement.nextElementSibling + playlistMetadataElement.nextElementSibling ); } }; @@ -436,6 +465,7 @@ const createPlaylistSummaryElement = ({ `${playlistDuration}`, "#86efac" ); + containerElement.appendChild(totalDuration); const videosCounted = createSummaryItem( @@ -443,6 +473,7 @@ const createPlaylistSummaryElement = ({ `${timestamps.length}`, "#fdba74" ); + containerElement.appendChild(videosCounted); const totalVideosInPlaylist = countTotalVideosInPlaylist(); @@ -453,6 +484,7 @@ const createPlaylistSummaryElement = ({ }`, "#fca5a5" ); + containerElement.appendChild(videosNotCounted); if (totalVideosInPlaylist <= 100) { @@ -473,16 +505,19 @@ const createPlaylistSummaryElement = ({ "http://www.w3.org/2000/svg", "svg" ); + iconElement.setAttribute("preserveAspectRatio", "xMidYMid meet"); iconElement.setAttribute("viewBox", "0 0 24 24"); iconElement.innerHTML = ``; + tooltipElement.appendChild(iconElement); const textElement = document.createElement("p"); textElement.textContent = chrome.i18n.getMessage("playlistSummary_tooltip"); + tooltipElement.appendChild(textElement); containerElement.appendChild(tooltipElement); @@ -491,6 +526,20 @@ const createPlaylistSummaryElement = ({ return containerElement; }; +const getPlaylistMetadataElement = () => { + const playlistMetadataElement = document.querySelector( + elementSelectors.playlistMetadata[isNewDesign() ? "new" : "old"] + ); + + if (!playlistMetadataElement) { + return document.querySelector( + elementSelectors.playlistMetadata.youtubePremium + ); + } + + return playlistMetadataElement; +}; + const isDarkMode = () => { return document.documentElement.getAttribute("dark") !== null; }; @@ -571,7 +620,6 @@ const createSortDropdown = (playlistObserver) => { const playlistElement = document.querySelector(elementSelectors.playlist); const videos = playlistElement.getElementsByTagName(elementSelectors.video); - const playlistSorter = new PlaylistSorter( event.target.getAttribute("value") ); @@ -593,8 +641,10 @@ const createSortDropdown = (playlistObserver) => { dropdownButtonElement.appendChild(dropdownButtonTextElement); dropdownButtonElement.appendChild(caretDownIcon); + dropdownElement.appendChild(dropdownButtonElement); dropdownElement.appendChild(dropdownOptionsElement); + containerElement.appendChild(labelElement); containerElement.appendChild(dropdownElement); diff --git a/src/modules/sorting/sort-by-upload-date/parsers/fr.js b/src/modules/sorting/sort-by-upload-date/parsers/fr.js new file mode 100644 index 0000000..0f859f6 --- /dev/null +++ b/src/modules/sorting/sort-by-upload-date/parsers/fr.js @@ -0,0 +1,28 @@ +export class FrUploadDateParser { + /** @param {Element} videoInfo */ + parse(videoInfo) { + const secondsByUnit = { + minute: 60, + heure: 60 * 60, + jour: 1 * 86400, + semaine: 7 * 86400, + mois: 30 * 86400, + an: 365 * 86400 + }; + + const uploadDateRegex = + /(?:Diffusé )?il y a (\d+) (minutes?|heures?|jours?|semaines?|mois|ans?)/u; + + const uploadDateElement = videoInfo.children[2]; + + const [value, unit] = uploadDateElement.textContent + .toLowerCase() + .match(uploadDateRegex) + .slice(1); + + const seconds = + secondsByUnit[unit] ?? secondsByUnit[unit.slice(0, -1)] ?? 1; + + return parseFloat(value) * seconds; + } +} diff --git a/src/modules/sorting/sort-by-upload-date/parsers/fr.test.js b/src/modules/sorting/sort-by-upload-date/parsers/fr.test.js new file mode 100644 index 0000000..6eb2db3 --- /dev/null +++ b/src/modules/sorting/sort-by-upload-date/parsers/fr.test.js @@ -0,0 +1,38 @@ +import test from "node:test"; +import assert from "node:assert"; +import { FrUploadDateParser } from "./fr.js"; + +test.describe("upload-date-parser/fr", () => { + const testCases = [ + { input: "il y a 1 minute", expected: 1 * 60 }, + { input: "il y a 2 minutes", expected: 2 * 60 }, + { input: "il y a 1 heure", expected: 1 * 3600 }, + { input: "il y a 2 heures", expected: 2 * 3600 }, + { input: "il y a 1 jour", expected: 1 * 86400 }, + { input: "il y a 2 jours", expected: 2 * 86400 }, + { input: "il y a 1 semaine", expected: 1 * 7 * 86400 }, + { input: "il y a 2 semaines", expected: 2 * 7 * 86400 }, + { input: "il y a 1 mois", expected: 1 * 30 * 86400 }, + { input: "il y a 2 mois", expected: 2 * 30 * 86400 }, + { input: "il y a 1 an", expected: 1 * 365 * 86400 }, + { input: "il y a 2 ans", expected: 2 * 365 * 86400 } + ]; + + const parser = new FrUploadDateParser(); + + for (const testCase of testCases) { + test(testCase.input, () => { + const variants = [testCase.input, `Diffusé ${testCase.input}`]; + + for (const variant of variants) { + const mockElement = { + children: ["", "", { textContent: variant }] + }; + + const result = parser.parse(mockElement); + + assert.equal(result, testCase.expected); + } + }); + } +}); diff --git a/src/modules/sorting/sort-by-upload-date/parsers/index.js b/src/modules/sorting/sort-by-upload-date/parsers/index.js index 910bb26..52ec8a3 100644 --- a/src/modules/sorting/sort-by-upload-date/parsers/index.js +++ b/src/modules/sorting/sort-by-upload-date/parsers/index.js @@ -1,5 +1,6 @@ import { EnUploadDateParser } from "./en"; import { EsUploadDateParser } from "./es"; +import { FrUploadDateParser } from "./fr"; import { PtUploadDateParser } from "./pt"; import { ZhHansCnUploadDateParser } from "./zh-Hans-CN"; import { ZhHantTwUploadDateParser } from "./zh-Hant-TW"; @@ -9,11 +10,14 @@ const UPLOAD_DATE_PARSERS_BY_LOCALE = { "en-GB": EnUploadDateParser, "en-IN": EnUploadDateParser, "en-US": EnUploadDateParser, - "es-ES": EsUploadDateParser, "es-419": EsUploadDateParser, + "es-ES": EsUploadDateParser, "es-US": EsUploadDateParser, - "pt-PT": PtUploadDateParser, + "fr": FrUploadDateParser, + "fr-CA": FrUploadDateParser, + "fr-FR": FrUploadDateParser, "pt-BR": PtUploadDateParser, + "pt-PT": PtUploadDateParser, "zh-Hans-CN": ZhHansCnUploadDateParser, "zh-Hant-TW": ZhHantTwUploadDateParser }; diff --git a/src/modules/sorting/sort-by-views/parsers/fr.js b/src/modules/sorting/sort-by-views/parsers/fr.js new file mode 100644 index 0000000..f7655a7 --- /dev/null +++ b/src/modules/sorting/sort-by-views/parsers/fr.js @@ -0,0 +1,25 @@ +export class FrViewsParser { + /** @param {Element} videoInfo */ + parse(videoInfo) { + const viewsElement = videoInfo.firstElementChild; + const [value, unit] = viewsElement.textContent + .trim() + .toLowerCase() + .replaceAll(/\s/g, " ") + .split(" "); + + const baseViews = parseFloat(value.replace(",", ".")); + + if (isNaN(baseViews)) { + return 0; + } + + if (unit === "k") { + return Math.round(baseViews * 1000); + } else if (unit === "m") { + return Math.round(baseViews * 1_000_000); + } else { + return Math.round(baseViews); + } + } +} diff --git a/src/modules/sorting/sort-by-views/parsers/fr.test.js b/src/modules/sorting/sort-by-views/parsers/fr.test.js new file mode 100644 index 0000000..8a5fc54 --- /dev/null +++ b/src/modules/sorting/sort-by-views/parsers/fr.test.js @@ -0,0 +1,29 @@ +import test from "node:test"; +import assert from "node:assert"; +import { FrViewsParser } from "./fr.js"; + +test.describe("views-parser/fr", () => { + const testCases = [ + { input: "1 vue", expected: 1 }, + { input: "420 vues", expected: 420 }, + { input: "2,4 k vues", expected: 2.4 * 1000 }, + { input: "870 k vues", expected: 870 * 1000 }, + { input: "1,4 M de vues", expected: 1.4 * 1_000_000 } + ]; + + const parser = new FrViewsParser(); + + for (const testCase of testCases) { + test(testCase.input, () => { + const mockElement = { + firstElementChild: { + textContent: testCase.input + } + }; + + const result = parser.parse(mockElement); + + assert.equal(result, testCase.expected); + }); + } +}); diff --git a/src/modules/sorting/sort-by-views/parsers/index.js b/src/modules/sorting/sort-by-views/parsers/index.js index 69048b5..3b45d72 100644 --- a/src/modules/sorting/sort-by-views/parsers/index.js +++ b/src/modules/sorting/sort-by-views/parsers/index.js @@ -1,6 +1,7 @@ import { EnViewsParser } from "./en"; import { EnInViewsParser } from "./en-IN"; import { EsViewsParser } from "./es"; +import { FrViewsParser } from "./fr"; import { PtViewsParser } from "./pt"; import { ZhHansCnViewsParser } from "./zh-Hans-CN"; import { ZhHantTwViewsParser } from "./zh-Hant-TW"; @@ -10,11 +11,14 @@ const VIEWS_PARSERS_BY_LOCALE = { "en-GB": EnViewsParser, "en-IN": EnInViewsParser, "en-US": EnViewsParser, - "es-ES": EsViewsParser, "es-419": EsViewsParser, + "es-ES": EsViewsParser, "es-US": EsViewsParser, - "pt-PT": PtViewsParser, + "fr": FrViewsParser, + "fr-CA": FrViewsParser, + "fr-FR": FrViewsParser, "pt-BR": PtViewsParser, + "pt-PT": PtViewsParser, "zh-Hans-CN": ZhHansCnViewsParser, "zh-Hant-TW": ZhHantTwViewsParser }; diff --git a/src/shared/data/element-selectors.js b/src/shared/data/element-selectors.js index 108e6b3..a4fa3d7 100644 --- a/src/shared/data/element-selectors.js +++ b/src/shared/data/element-selectors.js @@ -11,7 +11,8 @@ export const elementSelectors = { }, playlistMetadata: { old: "ytd-playlist-sidebar-renderer #items", - new: ".immersive-header-content .metadata-action-bar" + new: ".immersive-header-content .metadata-action-bar", + youtubePremium: ".yt-flexible-actions-view-model-wiz__action-row" }, video: "ytd-playlist-video-renderer", playlist: "ytd-playlist-video-list-renderer #contents",