// ==UserScript==
// @name Twonky Explorer
// @version v20250626.1600
// @description An alternative Web UI to navigate Twonky servers
// @author ltlwinston
// @match http*://*/twonkyexplorer*
// @grant GM_addElement
// @grant GM_setClipboard
// @require https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js
// @require https://cdn.jsdelivr.net/npm/@tailwindcss/[email protected]/dist/index.global.min.js
// @namespace https://gf.qytechs.cn/users/754595
// ==/UserScript==
let jQuery = unsafeWindow.jQuery || window.jQuery;
const ENABLE_JSON_CACHE = true;
function fixUrl(url) {
if (!url || typeof url !== 'string') {
return "";
}
const re = /((127\.\d+\.\d+\.\d+)|(10\.\d+\.\d+\.\d+)|(172\.1[6-9]\.\d+\.\d+)|(172\.2[0-9]\.\d+\.\d+)|(172\.3[0-1]\.\d+\.\d+)|(192\.168\.\d+\.\d+))(:\d+)?/g;
return url.replace(re,window.location.host);
}
async function loadServerStatus() {
let status = await fetch('/nmc/rss/server?start=0&fmt=json').then(r => r.json());
if (status) {
return status;
}
const serverData = {};
await fetch('/rpc/info_status').then(r => r.text()).then(s => s.split(/[\t\n ]/).forEach(i => {
const [k,v] = i.split('|');
switch(k) {
case 'server_udn':
serverData.bookmark = v;
break;
default:
serverData[k] = v;
break;
}
}));
return {item:[serverData]};
}
function addTreeview(uuid) {
const tv = jQuery('<div id="tv" style="max-width: 14em; max-height: 800px; overflow: auto;"></div>');
tv.css('float:', 'left');
tv.css('color', 'black');
tv.css('background', '#cccccc');
tv.append(createTreeLevel('/', uuid, []));
jQuery(document.body).append(tv);
createNewGallery();
}
function createNewGallery() {
const gallery = jQuery(`<div class="newgallery" style="float: left; overflow: auto; background: white;"></div>`);
jQuery(document.body).append(gallery);
}
function displayFolder(child) {
let title = child.title;
if ('childCount' in child.meta) {
title = `${title} [${child.meta.childCount}]`;
}
const link = jQuery(`
<a href="#" class="gtranslate">
${title}<br>
<span class="fa fa-folder" style="font-size: 100px"></span>
</a>`);
link.click((e)=>{
e.preventDefault();
e.stopPropagation();
navigateTo(child.title, child.bookmark, [], true);
});
const cont = jQuery(`<p style="display: inline-block; position: relative; padding: 0.5em;"></p>`);
cont.append(link);
jQuery('#grid').append(cont);
}
function popup(title, content) {
const $modal = jQuery(`
<div tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full bg-white/[var(--bg-opacity)] [--bg-opacity:70%] bg-opacity-30 place-items-center">
<div class="relative p-4 w-full max-w-5xl max-h-full">
<!-- Modal content -->
<div class="relative bg-white rounded-lg shadow-sm dark:bg-gray-700">
<!-- Modal header -->
<div class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600 border-gray-200">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
${title}
</h3>
<button type="button" class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm h-8 w-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white" data-modal-toggle="crypto-modal">
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
</svg>
<span class="sr-only">Close modal</span>
</button>
</div>
<!-- Modal body -->
<div class="p-4 md:p-5 text-sm">
<div class="bg-white p-4 text-pretty overflow-auto">${content}</div>
</div>
</div>
</div>
</div>`);
jQuery('button',$modal).click(() => $modal.remove());
jQuery('#content').append($modal);
setTimeout(()=>{ $modal.show(); }, 100);
}
function displayImage(child) {
const res = child.meta.res;
let src = fixUrl(res[res.length-1].value);
if (res.every(x => !!x.resolution)) {
src = fixUrl(res.reduce((acc, curr) => {
let [cw,ch] = curr.resolution.split('x');
let [aw,ah] = acc.resolution.split('x');
return aw*ah > cw*ch ? curr : acc;
}).value);
}
const href = fixUrl(res[0].value);
const ext = child.meta['pv:extension'];
delete child.meta.PersistentBookmark;
const cont = jQuery(`<div class=""></div>`);
/*
<br>
*/
const link = jQuery(`
<a href="${href}" target="_blank" class="grid place-items-center">
<img class="rounded-lg h-auto max-w-full hover:brightness-200 max-h-64 min-h-32" loading="lazy" loading="lazy" src="${src}">
</a>`);
cont.append(link);
const desc = jQuery(`<p class="text-xs text-center gtranslate"></p>`);
const infoBtn = jQuery(`<a class="pr" href="#"><i class="fa fa-info-circle" style="font-size: 2em;"></i></a>`);
infoBtn.click((e) => {
e.preventDefault();
e.stopPropagation();
popup(child.title, Object.keys(child.meta).map(key => key + ": " + child.meta[key]).join("<br>"));
});
desc.append(infoBtn);
desc.append(`<b class="flex-1 ms-3 whitespace-nowrap"></b>${child.title.length > 30 ? child.title.substring(0,20)+'...' : child.title} [${ext}]`);
link.append(desc);
jQuery('#grid').append(cont);
}
function displayVideo(child) {
let src = '';
let href = '';
let ext = '';
let duration = '';
const res = child.meta.res;
src = fixUrl(res[res.length-1].value);
if (!/(jpg|jpeg|png|gif)/i.test(src)) {
src = '/resources/webbrowse/nocover_video.jpg';
}
href = fixUrl(res[0].value);
ext = child.meta['pv:extension'];
duration = child.meta['pv:duration'] || '';
delete child.meta.PersistentBookmark;
const cont = jQuery(`<div class=""></div>`);
/*
<br>
*/
const link = jQuery(`
<a href="${href}" target="_blank" class="grid place-items-center">
<div class="relative grid place-items-center">
<span class="absolute bottom-1 text-sm text-shadow-[1px_1px_0px] text-shadow-white">${duration}</span>
<img class="rounded-lg h-auto max-w-full hover:brightness-200 max-h-64 min-h-32" loading="lazy" loading="lazy" src="${src}">
</div>
</a>`);
cont.append(link);
const desc = jQuery(`<p class="text-xs text-center gtranslate"></p>`);
const infoBtn = jQuery(`<a class="pr" href="#"><i class="fa fa-info-circle" style="font-size: 2em;"></i></a>`);
infoBtn.click((e) => {
e.preventDefault();
e.stopPropagation();
popup(child.title, Object.keys(child.meta).map(key => key + ": " + child.meta[key]).join("<br>"));
});
desc.append(infoBtn);
desc.append(`<b class="flex-1 ms-3 whitespace-nowrap"></b>${child.title.length > 30 ? child.title.substring(0,20)+'...' : child.title} [${ext}]`);
link.append(desc);
jQuery('#grid').append(cont);
}
function fillGrid($grid, children) {
children.item.forEach(child => {
try{
switch (child.meta['upnp:class']) {
case 'object.item.imageItem.photo':
displayImage(child);
break;
case 'object.item.videoItem':
case 'object.item.videoItem.movie':
displayVideo(child);
break;
case 'object.container':
case 'object.container.storageFolder':
case 'object.container.album.photoAlbum':
case 'object.container.playlistContainer':
displayFolder(child);
break;
default:
console.error('BOH!',child);
}
}catch(e){
console.error(e);
}
});
}
async function openFolder(bookmark, parents, start=0, count=100) {
const $content = jQuery('#content');
$content.empty();
$content.append(`<h1>LOADING <i class="fa fa-spinner fa-spin" style="font-size:1.2em"></i></h1>`);
const children = await getJsonByBookmark(bookmark, start, count)
.catch(e => {
$content.empty();
$content.append(`<h1>Error loading data: ${e}</h1>`);
});
if (!children) {
return;
}
if (!children.item || children.item.length === 0) {
if (start === 0) {
$content.empty();
$content.append(`<h1>EMPTY</h1>`);
}
return;
}
$content.empty();
$content.append(`<div id="breadcrumb" class="text-left"></div>`);
const $breadcrumb = jQuery('#breadcrumb');
let breadcrumbUrl = '/twonkyexplorer'
parents.forEach(p => {
breadcrumbUrl += `/${encodeURIElem(p.name,p.bookmark)}`;
$breadcrumb.append(`<a href="${breadcrumbUrl}">${p.name}</a> » `)
});
breadcrumbUrl += `/${encodeURIElem(children.title,bookmark)}`;
$breadcrumb.append(`<a href="${breadcrumbUrl}">${children.title}</a>`)
$content.append(`<h1 class="title mb-5 mt-5 text-2xl">${children.title} [${children.childCount}]</h1>`)
const $grid = jQuery(`<div id="grid" class="grid grid-cols-4 md:grid-cols-6 gap-4"></div>`);
$content.append($grid);
fillGrid($grid, children);
if (children.childCount>start+count) {
const $btnMore = jQuery(`<button type="button" class="focus:outline-none text-white bg-yellow-400 hover:bg-yellow-500 focus:ring-4 focus:ring-yellow-300 font-medium rounded-lg text-3xl mt-10 px-5 py-2.5 me-2 mb-30 dark:focus:ring-yellow-900" id="btn-more">More...</button>`);
$btnMore.data('start', start+count);
$btnMore.data('count', count);
$btnMore.click(async (e) => {
e.preventDefault();
e.stopPropagation();
const moreStart = $btnMore.data('start');
const moreCount = $btnMore.data('count');
$btnMore.attr('disabled',true);
$btnMore.html('Loading more... <i class="fa fa-spinner fa-spin"></i>');
const moreChildren = await getJsonByBookmark(bookmark, moreStart, moreCount)
.catch(e => {
$grid.append(`<h1>Error loading more data: ${e}</h1>`);
});
if (moreChildren) {
fillGrid($grid, moreChildren);
}
$btnMore.removeAttr('disabled');
$btnMore.html('More...');
if (children.childCount<=moreStart+moreCount) {
$btnMore.hide();
}else{
$btnMore.data('start', moreStart+moreCount);
$btnMore.data('count', moreCount);
}
});
$content.append($btnMore);
$btnMore.show();
} else {
jQuery('#btn-more').remove();
}
}
function createTreeLevel(name, bookmark, parents=[], container=false) {
const levelElem = jQuery(`<li></li>`);
const linkElem = jQuery(`<a href="#" class="flex items-center p-1 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group"></a>`);
const nameElem = jQuery(`<span class="flex-1 ms-3 whitespace-nowrap gtranslate">${name}</span>`)
const loadElem = jQuery(`<i class="fa fa-spin fa-spinner"></i>`)
const openElem = jQuery(`<a class="inline-flex items-center justify-center px-2 ms-3 text-sm font-medium hover:bg-green-100 text-gray-800 bg-gray-100 rounded-full dark:bg-gray-700 dark:text-gray-300">→</a>`);
levelElem.append(linkElem);
linkElem.append(nameElem);
linkElem.append(loadElem);
linkElem.append(openElem);
loadElem.hide();
const childrenElem = jQuery(`<ul class="py-2 space-y-2 pl-5 text-sm"></ul>`);
childrenElem.hide();
levelElem.append(childrenElem);
openElem.click(async function (event){
event.preventDefault();
event.stopPropagation();
loadElem.show();
navigateTo(name, bookmark, parents, false);
loadElem.hide();
});
linkElem.click(async function(){
event.preventDefault();
event.stopPropagation();
if(!container) {
loadElem.show();
await openFolder(bookmark, [], loadElem, 0, 100);
loadElem.hide();
return;
}
if( childrenElem.data('loaded') ) {
childrenElem.toggle();
}else {
loadElem.show();
getJsonByBookmark(bookmark).then(children => {
if (!children.item || children.item.length === 0) {
childrenElem.append(jQuery('<li>Empty</li>'));
} else {
children.item.forEach(child => {
let title = child.title;
if ('childCount' in child.meta) {
title = `${title} [${child.meta.childCount}]`;
}
childrenElem.append(createTreeLevel(title, child.bookmark, [...parents,{name,bookmark}], /object\.container/.test(child.meta['upnp:class'])));
});
childrenElem.show();
childrenElem.data('loaded', true);
}
loadElem.hide();
}).catch(e => {
console.error('Error on getJsonByBookmark: ',e);
childrenElem.empty();
childrenElem.append(`<p>ERROR: ${e}</p>`);
childrenElem.show();
loadElem.hide();
});
}
});
return levelElem;
}
function buildUrl(bookmark, start=0, count=100) {
const uuid = getUuidFromBookmark(bookmark);
return `/nmc/rss/server/RB${uuid},0/IB${bookmark}?start=${start}&count=${count}&fmt=json`;
}
function dataCachePut(id, data) {
window.localStorage.setItem(id, JSON.stringify(data));
}
function dataCacheGet(id) {
try {
return JSON.parse(window.localStorage.getItem(id));
}catch(e) {
console.error(e);
return null;
}
}
function getUuidFromBookmark(bookmark) {
return bookmark.split(',')[0];
}
async function getJsonByBookmark(bookmark, start=0, count=100) {
const cacheId = `${bookmark}_${start}_${count}`;
const existing = dataCacheGet(cacheId);
if (existing && ENABLE_JSON_CACHE) {
return new Promise((resolve) => resolve(existing));
}
console.log(`getJsonByBookmark("${bookmark}", ${start}, ${count})`);
const json = await fetch(buildUrl(bookmark, start, count)+'&fmt=json').then(r => r.json());
dataCachePut(cacheId, json);
return json;
}
unsafeWindow.changeLang = function (lang) {
console.log('LANG: '+lang);
}
function showNoServerMessage($content) {
$content.empty();
$content.append(`<h1>No compatible Twonky Server found!<br>Refresh to return to original page</h1>`);
}
function handleUrl(url) {
const bookmarks = url.replace(/\/twonkyexplorer(\/)?/,'').split('/');
console.log('WTF???', bookmarks);
if (bookmarks.length>0) {
const last = bookmarks.pop();
if (last) {
const {name,bookmark} = decodeURIElem(last);
const parents = bookmarks.map(b => decodeURIElem(b));
console.log(`Opening Folder "${name}" @ ${bookmark} parents: `, parents);
openFolder(bookmark, parents);
}
}
}
function routingSetup() {
window.navigation.addEventListener('navigate', navigateEvent => {
const url = new URL(navigateEvent.destination.url);
if (!url.pathname.match(/twonkyexplorer/)) {
return;
}
navigateEvent.intercept({handler: () => handleUrl(url.pathname)});
});
handleUrl(new URL(window.navigation.currentEntry.url).pathname);
}
function encodeURIElem(name, bookmark) {
return `${encodeURIComponent(name)}_@TE@_${encodeURIComponent(bookmark)}`;
}
function decodeURIElem(encoded) {
const [name,bookmark] = encoded.split('_@TE@_').map(x => decodeURIComponent(x));
return {name, bookmark};
}
function navigateTo(name, bookmark, parents=[], append=false) {
let url = '/twonkyexplorer';
parents.forEach(p => {url += '/' + encodeURIElem(p.name,p.bookmark)});
url += '/' + encodeURIElem(name, bookmark);
if (append) {
url = new URL(window.navigation.currentEntry.url).pathname + `/${encodeURIElem(name,bookmark)}`;
}
window.navigation.navigate(url);
}
async function initialSetup() {
document.title = 'Twonky Explorer';
const $body = jQuery(document.body);
const $head = jQuery(document.head);
$body.empty();
$head.append(`<meta name="viewport" content="width=device-width, initial-scale=1">`);
jQuery('link[type="text/css"]').remove();
GM_addElement('link',{
rel: "stylesheet",
href: "//cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.css"
});
/*
<div class="mb-5">
Translate:
<select onchange="changeLang(this.value)">
<option selected>Original</option>
<option value="it">ITA</option>
<option value="en">ENG</option>
</select>
</div>
*/
const $layout = jQuery(`
<div id="wrapper" class="">
<aside id="default-sidebar" class="fixed resize-x top-0 left-0 z-40 w-80 h-screen transition-transform -translate-x-full sm:translate-x-0" aria-label="Sidebar" aria-hidden="true">
<div class="h-full px-3 py-4 overflow-y-auto bg-gray-50 dark:text-white dark:bg-gray-800">
<h1 class="pb-5 text-red-700 text-xl">
<div class="relative inline-block text-left float-right">
<button id="btn-clean-cache" data-tooltip-target="tooltip-cache" type="button" class="text-white bg-gray-700 hover:bg-gray-800 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-gray-600 dark:hover:bg-gray-700 dark:focus:ring-blue-800"><i class="fa fa-eraser"></i></button>
</div>
Twonky Explorer<br><small>by ltlwinston</small>
</h1>
<ul class="space-y-2 font-medium" id="navigation"></ul>
</div>
</aside>
<div class="sm:ml-80 resize-x p-5 text-center" id="content">
<h1>Welcome to Twonky Explorer!</h1>
<h2>Use sidebar links to start your journey in this Twonky Server!</h2>
</div>
</div>
`)
$body.append($layout);
const $nav = jQuery('#navigation');
const $content = jQuery('#content');
$nav.append(`<h1>LOADING <i class="fa fa-spinner fa-spin" style="font-size:1.2em"></i></h1>`);
const status = await loadServerStatus();
jQuery('#btn-clean-cache').click(e => {
window.localStorage.clear();
});
if (!status || !status.item || status.item.length === 0) {
showNoServerMessage($content);
} else {
$nav.empty();
let foundOne = false;
status.item.forEach(server => {
if (server.title && server.bookmark) {
foundOne = true;
$nav.append(createTreeLevel(server.title, server.bookmark, [], true));
}
});
if (!foundOne) {
showNoServerMessage($content);
}
}
routingSetup();
}
(async function() {
'use strict';
initialSetup();
})();