Skip to content

Instantly share code, notes, and snippets.

@KennethScott
Last active April 14, 2024 19:26
Show Gist options
  • Save KennethScott/fd898f357d27d46a727d0d14ef6166f9 to your computer and use it in GitHub Desktop.
Save KennethScott/fd898f357d27d46a727d0d14ef6166f9 to your computer and use it in GitHub Desktop.
Channel Management Helper for ChannelsDVR (Bookmarklet version)
/*
This code is intended to be used as a bookmarklet for use with ChannelsDVR. It provides simple functionality to
sort, update, and filter channel lineups.
It is expected that you are on a Manage Lineup popup for a given Channel Source within the Settings page when you
click the bookmarklet.
You may use any bookmarklet maker site you wish that is capable of handling ES6 (or newer) javascript.
There are no external dependencies.
Recommended instructions for creating the bookmarklet using https://caiorss.github.io/bookmarklet-maker/
1) Copy & paste this code into the Code box. *Make sure to clear the "alert('hello world');" example code before pasting.
2) Click the "Generate Bookmarlet" button
3) Drag the blue-highlighted link button to your Bookmarks (and rename if you wish). You can simply ignore the
"Output" and "Html code" boxes entirely.
Known Issue with Pluto:
If you are using an m3u source for Pluto that does *not* provide the channel description, the tooltip description will only
work if you access your Channels server via an IP & port url.
*/
(async () => {
const dropdownId = 'channels-helper-commands';
const tooltipId = 'tooltipSummary';
const baseSelectors = {
modalDialog: '.modal-dialog',
modalCard: '.modal-body > .card',
listGroup: '.list-group',
listGroupItem: '.list-group-item',
visibleListGroupItem: '.list-group-item:not(.d-none)'
};
const selectors = {
toolbar: `${baseSelectors.modalCard} > .card-header`,
channelRow: `${baseSelectors.modalCard} > ${baseSelectors.listGroup} > ${baseSelectors.listGroupItem}`,
channelContent: '.row > .col-sm-6 > span', // within channelRow
favorites: `${baseSelectors.modalCard} ${baseSelectors.visibleListGroupItem} [aria-label="favorited"]>.glyphicon-heart`,
allFavorites: `${baseSelectors.modalCard} [aria-label="favorited"]>.glyphicon-heart`,
unfavorites: `${baseSelectors.modalCard} ${baseSelectors.visibleListGroupItem} [aria-label="not favorited"]>.glyphicon-heart-empty`,
allUnfavorites: `${baseSelectors.modalCard} [aria-label="not favorited"]>.glyphicon-heart-empty`,
blocked: `${baseSelectors.modalCard} ${baseSelectors.visibleListGroupItem} [aria-label="hidden"]>.glyphicon-ban-circle`,
allBlocked: `${baseSelectors.modalCard} [aria-label="hidden"]>.glyphicon-ban-circle`,
unblocked: `${baseSelectors.modalCard} ${baseSelectors.visibleListGroupItem} [aria-label="not hidden"]>.glyphicon-ban-circle`,
allUnblocked: `${baseSelectors.modalCard} [aria-label="not hidden"]>.glyphicon-ban-circle`,
};
function getToolbar() {
return document.querySelector(selectors.toolbar);
}
function getModalDialog() {
return document.querySelector(baseSelectors.modalDialog);
}
function getChannelRows() {
return document.querySelectorAll(selectors.channelRow);
}
// Private helper functions
function sortItems(compareFunction) {
return function() {
loading(true);
const channelRows = getChannelRows();
const items = Array.from(channelRows);
items.sort(compareFunction);
const container = channelRows[0].parentNode;
const containerParent = container.parentNode;
containerParent.removeChild(container);
container.innerHTML = '';
// use a DocumentFragment to minimize reflows/repaints
const fragment = document.createDocumentFragment();
items.forEach(function(item) {
fragment.appendChild(item);
});
container.appendChild(fragment);
containerParent.appendChild(container);
loading(false);
}
}
// Get text content from passed channel row
function getChannelContent(channelRow) {
// <span>
// <span>10456</span> 0-Channel Number
// "&nbsp;" 1
// "Pluto TV Spotlight" 2-Channel Name
// "&nbsp;" 3
// <span class="badge badge-primary mx-1 category">Movies</span>
// <span class="tooltip-target glyphicon glyphicon-info-sign mx-1" title="Some channel description..."></span>
// </span>
return channelRow.querySelector(selectors.channelContent);
}
function getChannelName(channelRow) {
const content = getChannelContent(channelRow);
return content.childNodes[2].textContent.trim(); // 2 = Channel Name
}
function getChannelNumber(channelRow) {
const content = getChannelContent(channelRow);
return content.childNodes[0].textContent.trim(); // 0 = Channel Number
}
function getChannelCategory(channelRow) {
const content = getChannelContent(channelRow);
return content.querySelector('.category')?.textContent.trim();
}
function getChannelDescription(channelRow) {
const content = getChannelContent(channelRow);
return content.querySelector('.tooltip-target')?.getAttribute('title').trim();
}
function compareByName(a, b) {
const textA = getChannelName(a).toLowerCase();
const textB = getChannelName(b).toLowerCase();
return textA.localeCompare(textB);
}
function compareByNumber(a, b) {
const textA = getChannelNumber(a);
const textB = getChannelNumber(b);
return textA.localeCompare(textB, 'en', { numeric: true });
}
function compareByCategoryThenName(a, b) {
// If category doesn't exist, just sort by name
const categoryA = getChannelCategory(a)?.toLowerCase() ?? "";
const categoryB = getChannelCategory(b)?.toLowerCase() ?? "";
// First, compare by category
const categoryComparison = categoryA.localeCompare(categoryB);
if (categoryComparison !== 0) {
return categoryComparison;
}
// If categories are the same, compare by name
const nameA = getChannelName(a).toLowerCase();
const nameB = getChannelName(b).toLowerCase();
return nameA.localeCompare(nameB);
}
function compareByCategoryThenNumber(a, b) {
// If category doesn't exist, just sort by number
const categoryA = getChannelCategory(a)?.toLowerCase() ?? "";
const categoryB = getChannelCategory(b)?.toLowerCase() ?? "";
// First, compare by category
const categoryComparison = categoryA.localeCompare(categoryB);
if (categoryComparison !== 0) {
return categoryComparison;
}
// If categories are the same, compare by number
const numberA = getChannelNumber(a);
const numberB = getChannelNumber(b);
return numberA.localeCompare(numberB, 'en', { numeric: true });
}
function loading(show) {
document.querySelector('.spinner-border').style.display = show ? 'block' : 'none';
getDropdown().disabled = show;
}
function showHideThem(selector) {
return function() {
Array.from(document.querySelectorAll(selector)).forEach(function(el,i) {
if (i%2==0) {
const elRow = el.closest(selectors.channelRow);
if (elRow.classList.contains('d-none')) {
elRow.classList.remove('d-none');
} else {
elRow.classList.add('d-none');
}
}
});
}
}
function showThemAll() {
return function() {
Array.from(document.querySelectorAll(selectors.channelRow)).forEach(function(row,i) {
if (row.classList.contains('d-none')) {
row.classList.remove('d-none');
}
});
}
}
function clickThem(selector, confirmationMessage) {
return function() {
if (confirmationMessage && !confirm(confirmationMessage)) {
return; // Exit if the user cancels the confirmation
}
const elements = Array.from(document.querySelectorAll(selector));
let index = 0;
function processNext() {
if (index < elements.length) {
if (index % 2 === 0) elements[index].click();
index++;
setTimeout(processNext, 0); // Schedule the next iteration
} else {
loading(false); // Hide spinner after processing all elements
}
}
loading(true); // Show the spinner
processNext(); // Start the process
}
}
async function augmentChannels() {
const channelRows = getChannelRows();
let plutoChannels = [];
try { plutoChannels = await fetchPlutoChannels() ?? []; }
catch (ex) { }
for (const channelRow of channelRows) {
const channelNumber = getChannelNumber(channelRow);
const category = window.DVR.channels[channelNumber]?.Categories?.toString() ?? "";
if (category) {
const span = document.createElement('span');
span.className = 'badge badge-primary mx-1 category';
span.textContent = category;
getChannelContent(channelRow).appendChild(span);
}
const stationId = window.DVR.channels[channelNumber]?.Station;
// Different m3u sources may or may not provide channel data in different properties
// For the tooltip description, we'll first see if it's available in the Description property
let description = window.DVR.channels[channelNumber]?.Description;
// If not, we'll try to get it from the Pluto API as long as we have the GUID station ID
if (!description && stationId) {
description = plutoChannels[stationId]?.summary;
}
// As long as we managed to get from somewhere, we'll load it
if (description) {
const span = document.createElement('span');
span.classList.add("tooltip-target", "glyphicon", "glyphicon-info-sign", "mx-1");
span.title = description;
getChannelContent(channelRow).appendChild(span)
}
}
}
function getDropdown() {
return document.querySelector(`#${dropdownId}`);
}
function createDropdown() {
const commands = {
'': 'Select a command...',
'block': 'Block Channels',
'unblock': 'Unblock Channels',
'showHideBlocked': 'Show/Hide Blocked Channels',
'showHideUnblocked': 'Show/Hide Unblocked Channels',
'favorite': 'Favorite Channels',
'unfavorite': 'Unfavorite Channels',
'showHideFavorites': 'Show/Hide Favorites',
'showHideUnfavorites': 'Show/Hide Unfavorites',
'sortByCategoryThenChannelName': 'Sort by Category then Channel Name',
'sortByCategoryThenChannelNumber': 'Sort by Category then Channel Number',
'sortByChannelName': 'Sort by Channel Name',
'sortByChannelNumber': 'Sort by Channel Number',
'showThemAll': 'Show all Channels'
};
const ddl = document.createElement('select');
ddl.id = dropdownId;
ddl.name = ddl.id;
ddl.classList.add('form-control', 'form-control-sm', 'float-right', 'py-0');
ddl.style.cssText = 'max-width: 15rem; max-height: 1.5rem;';
ddl.addEventListener('change', function() {
const action = this.value;
if (action && ChannelsHelper[action]) {
ChannelsHelper[action]();
// Reset the dropdown to its default value after the action
this.value = '';
}
});
for (var key in commands) {
const option = document.createElement("option");
option.value = key;
option.text = commands[key];
ddl.appendChild(option);
}
return ddl;
}
function createFilter() {
const filter = document.createElement('input');
filter.id = 'channels-helper-filter';
filter.name = filter.id;
filter.type = 'search';
filter.classList.add('form-control', 'form-control-sm', 'float-right', 'mr-5');
filter.style.cssText = 'max-width: 15rem; max-height: 1.5rem;';
filter.placeholder = 'Type to filter...';
filter.addEventListener('input', function(e) {
const filterText = e.target.value.toLowerCase();
filterChannels(filterText);
});
return filter;
}
function filterChannels(filterText) {
const channelRows = getChannelRows();
channelRows.forEach(channelRow => {
const text = channelRow.textContent.toLowerCase();
if (text.includes(filterText)) {
channelRow.classList.remove('d-none');
} else {
channelRow.classList.add('d-none');
}
});
}
function createSpinner() {
const spinner = document.createElement('div');
spinner.classList.add('spinner-border', 'spinner-border-sm', 'float-right');
spinner.style.cssText = 'margin-right: .5rem; margin-top: .2rem; display: none;';
return spinner;
}
async function fetchPlutoChannels() {
try {
const url = 'http://api.pluto.tv/v2/channels';
const response = await fetch(url);
if (!response.ok) {
throw new Error('Network response was not ok');
}
const data = await response.json();
const lookupTable = data.reduce((acc, channel) => {
if (channel._id && channel.name && channel.summary) {
acc[channel._id] = { name: channel.name, summary: channel.summary };
}
return acc;
}, {});
return lookupTable;
} catch (error) {
console.error('Problem fetching pluto channels listing:', error);
}
}
const ChannelsHelper = {
block: clickThem(selectors.unblocked, 'Are you sure you want to block all channels shown? This may take a minute...'),
unblock: clickThem(selectors.blocked, 'Are you sure you want to unblock all channels shown? This may take a minute...'),
showHideBlocked: showHideThem(selectors.allBlocked),
showHideUnblocked: showHideThem(selectors.allUnblocked),
favorite: clickThem(selectors.unfavorites, 'Are you sure you want to favorite all channels shown? This may take a minute...'),
unfavorite: clickThem(selectors.favorites, 'Are you sure you want to unfavorite all channels shown? This may take a minute...'),
showHideFavorites: showHideThem(selectors.allFavorites),
showHideUnfavorites: showHideThem(selectors.allUnfavorites),
sortByCategoryThenChannelName: sortItems(compareByCategoryThenName),
sortByCategoryThenChannelNumber: sortItems(compareByCategoryThenNumber),
sortByChannelName: sortItems(compareByName),
sortByChannelNumber: sortItems(compareByNumber),
showThemAll: showThemAll(),
init: async function() {
getModalDialog().style.cssText = 'max-width: 1000px;'
const spinner = createSpinner();
const filter = createFilter();
const ddl = createDropdown();
const toolbar = getToolbar();
if (toolbar && !getDropdown()) {
toolbar.append(ddl);
toolbar.append(spinner);
toolbar.append(filter);
}
loading(true);
await augmentChannels();
loading(false);
}
};
await ChannelsHelper.init();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment