var Clay = require('@rebble/clay'); var clayConfig = require('./config'); var clay = new Clay(clayConfig, null, { autoHandleEvents: false }); var keys = require('message_keys'); var NavScope = Object.freeze({ NAV_SCOPE_MAIN: 0, NAV_SCOPE_SEARCH: 1, NAV_SCOPE_ARTISTS: 2, NAV_SCOPE_ALBUMS: 3, NAV_SCOPE_ALBUM_TRACKS: 4, }); var cfg = JSON.parse(localStorage.getItem('clay-settings')); var currentArtist = null; ////////////////////// GENERAL VARS ////////////////////// var QUEUE_SIZE = 100; var USER_ID_CACHE_KEY = 'user-id'; var jfSessionId; ////////////////////// LIBRARY CACHE ////////////////////// var LIBRARY_CACHE_KEY = 'library-cache'; var lookupMap = new Map(); var jfIdToSeqIdMap = new Map(); var allJfIds = []; function saveLibraryCache() { function encodeValue(value) { if (value instanceof Map) { const entries = []; for (const [k, v] of value.entries()) { entries.push([k, encodeValue(v)]); } return { __type: 'Map', entries: entries }; } if (Array.isArray(value)) { return value.map(encodeValue); } return value; } try { localStorage.setItem(LIBRARY_CACHE_KEY, JSON.stringify({ lookupMap: encodeValue(lookupMap), jfIdToSeqIdMap: encodeValue(jfIdToSeqIdMap), allJfIds: allJfIds })); } catch (err) { console.log('failed to save library cache: ' + err); } } function loadLibraryCache() { function decodeValue(value) { if (value && typeof value === 'object' && value.__type === 'Map') { const map = new Map(); for (const [k, v] of value.entries) { map.set(k, decodeValue(v)); } return map; } if (Array.isArray(value)) { return value.map(decodeValue); } return value; } const raw = localStorage.getItem(LIBRARY_CACHE_KEY); if (!raw) { return; } try { const cache = JSON.parse(raw); lookupMap = decodeValue(cache.lookupMap); jfIdToSeqIdMap = decodeValue(cache.jfIdToSeqIdMap); allJfIds = cache.allJfIds || []; } catch (err) { console.log('failed to load library cache: ' + err); } } ////////////////////// SYNC ////////////////////// function toggleReadyState(ready) { let bestSessionInfo = { sessionId: "", lastActiveTime: new Date("1970-01-01T00:00:00.0000000Z") }; // get sessionId from Jellyfin callAPI("GET", cfg.PRIV_BASE_URL + "/Sessions", function (sessionIdResp) { if (sessionIdResp === null) { return; } const sessionIdObj = JSON.parse(sessionIdResp); let lastActiveTime; for (const session of sessionIdObj) { if (session.UserName == cfg.PRIV_USERNAME) { lastActiveTime = new Date(session.LastActivityDate); if (lastActiveTime > bestSessionInfo.lastActiveTime) { bestSessionInfo = { sessionId: session.Id, lastActiveTime: lastActiveTime }; } } } jfSessionId = bestSessionInfo.sessionId; const readyDict = {}; readyDict[keys.PKJS_READY] = ready; Pebble.sendAppMessage(readyDict); }); } function syncLibrary() { toggleReadyState(false); // store cache of library info from Jellyfin if (cfg.PRIV_BASE_URL && cfg.PRIV_API_KEY && cfg.PRIV_USERNAME) { callAPI("GET", cfg.PRIV_BASE_URL + "/Users", function (userIdResp) { // get userId from Jellyfin if (userIdResp === null) { return; } const userIdObj = JSON.parse(userIdResp); let userId; for (const user of userIdObj) { if (user.Name === cfg.PRIV_USERNAME) { userId = user.Id; break; } } // get library response from Jellyfin callAPI("GET", cfg.PRIV_BASE_URL + "/Items?userId=" + userId + "&recursive=true&includeItemTypes=Audio&sortBy=AlbumArtist,Artist,ProductionYear,Album,IndexNumber,Name", function (libResp) { if (libResp === null) return; const libObj = JSON.parse(libResp); // format response for jellyPod //// build lookupMap let lastAlbumInfo = { AlbumArtist: "", AlbumName: ""}; //let lastAlbumInfo = { AlbumArtist: "", AlbumName: "", AlbumYear: 0 }; // TODO FUTURE USE let currentSeqId = 0; allJfIds = []; lookupMap = new Map(); jfIdToSeqIdMap = new Map(); for (const track of libObj.Items) { if (!track.Name) continue; let albumArtist = "unknown artist"; if (track.AlbumArtist) { albumArtist = track.AlbumArtist; } else if (track.Artists.length > 0) { albumArtist = track.Artists[0]; } const albumName = track.Album ? track.Album : "unknown album"; // insert every track into lookupMap const firstChar = Array.from(albumArtist)[0].toUpperCase(); if (!lookupMap.has(firstChar)) { lookupMap.set(firstChar, new Map()); } const artistToAlbumsMap = lookupMap.get(firstChar); if (!artistToAlbumsMap.has(albumArtist)) { artistToAlbumsMap.set(albumArtist, new Map()); } const albumToTracksMap = artistToAlbumsMap.get(albumArtist); if (!albumToTracksMap.has(albumName)) { albumToTracksMap.set(albumName, []); } albumToTracksMap.get(albumName).push({ TrackName: track.Name, JfId: track.Id }); jfIdToSeqIdMap.set(track.Id, currentSeqId); allJfIds.push(track.Id); currentSeqId++; // consider the album to have changed if the album name OR artist changed if (lastAlbumInfo.AlbumArtist !== albumArtist || lastAlbumInfo.AlbumName !== albumName) { lastAlbumInfo = { AlbumArtist: albumArtist, AlbumName: albumName, //AlbumYear: track.ProductionYear ? track.ProductionYear : 1970 // TODO FUTURE USE }; } } //console.log(JSON.stringify(lookupMap, (k, v) => v instanceof Map ? Object.fromEntries(v) : v)); saveLibraryCache(); toggleReadyState(true); }); }); } } ////////////////////// EVENT HANDLING ////////////////////// Pebble.addEventListener('showConfiguration', function () { Pebble.openURL(clay.generateUrl()); }); Pebble.addEventListener('webviewclosed', function(e) { if (!e || !e.response) { return; } // persist settings try { clay.getSettings(e.response); } catch (err) { return; } cfg = JSON.parse(localStorage.getItem('clay-settings')); syncLibrary() }); Pebble.addEventListener("ready", function () { loadLibraryCache(); toggleReadyState(true); }); // PKJS seems to change its mind on how to index message keys, so this is necessary... :/ function payloadGet(payload, name) { if (payload[name] !== undefined) return payload[name]; if (payload[keys[name]] !== undefined) return payload[keys[name]]; return undefined; } function ringSlice(arr, start, end) { const n = arr.length; if (n === 0) return []; const count = Math.max(0, end + 1 - start); return Array.from({ length: count }, (_, k) => arr[(((start + k) % n) + n) % n]); } // Fisher-Yates shuffles a copy of an array function shuffledCopy(arr) { const result = arr.slice(); for (let i = result.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); const tmp = result[i]; result[i] = result[j]; result[j] = tmp; } return result; } // flattens an album to tracks map into jfIds function albumMapJfIds(albumToTracksMap) { const jfIds = []; for (const tracks of albumToTracksMap.values()) { for (const track of tracks) { jfIds.push(track.JfId); } } return jfIds; } // shuffle the order of whole groups while keeping each group's tracks together function groupedShuffle(groups, limit) { const result = []; for (const group of shuffledCopy(groups)) { for (const jfId of group) { result.push(jfId); if (result.length >= limit) { return result; } } } return result; } Pebble.addEventListener("appmessage", function (dict) { //console.log("payload", JSON.stringify(dict.payload)); //console.log("expected NAV_SCOPE key", keys.BIDIR_MENU_NAV_SCOPE); const scope = payloadGet(dict.payload, "BIDIR_MENU_NAV_SCOPE"); const subscope = payloadGet(dict.payload, "OUT_MENU_NAV_SUBSCOPE"); const seqId = payloadGet(dict.payload, "OUT_PLAY_SEQ_ID"); const shuffleMode = payloadGet(dict.payload, "OUT_SHUFFLE_MODE"); if (seqId !== undefined) { callAPI("POST", cfg.PRIV_BASE_URL + "/Sessions/" + jfSessionId + "/Playing?playCommand=PlayNow&itemIds=" + ringSlice(allJfIds, seqId, seqId+QUEUE_SIZE), null); } if (scope !== undefined) { // scope shuffle if (shuffleMode) { // build a shuffled queue of jfIds for the current scope let queuedJfIds = []; switch (scope) { case NavScope.NAV_SCOPE_MAIN: if (shuffleMode == 1) { // choose 100 random jfIds from the entire library (truly random order) queuedJfIds = shuffledCopy(allJfIds).slice(0, QUEUE_SIZE); } else { // choose 100 random jfIds from the entire library (grouped by artist) const artistGroups = []; for (const artistToAlbumsMap of lookupMap.values()) { for (const albumToTracksMap of artistToAlbumsMap.values()) { artistGroups.push(albumMapJfIds(albumToTracksMap)); } } queuedJfIds = groupedShuffle(artistGroups, QUEUE_SIZE); } break; case NavScope.NAV_SCOPE_ALBUMS: { const artistToAlbumsMap = lookupMap.get(Array.from(subscope)[0].toUpperCase()); const albumToTracksMap = artistToAlbumsMap ? artistToAlbumsMap.get(subscope) : null; if (albumToTracksMap) { if (shuffleMode == 1) { // choose 100 random jfIds from the current artist (truly random order) queuedJfIds = shuffledCopy(albumMapJfIds(albumToTracksMap)).slice(0, QUEUE_SIZE); } else { // choose 100 random jfIds from the current artist (grouped by album) const albumGroups = []; for (const tracks of albumToTracksMap.values()) { albumGroups.push(tracks.map((track) => track.JfId)); } queuedJfIds = groupedShuffle(albumGroups, QUEUE_SIZE); } } break; } case NavScope.NAV_SCOPE_ALBUM_TRACKS: { const artistToAlbumsMap = lookupMap.get(Array.from(currentArtist)[0].toUpperCase()); const albumToTracksMap = artistToAlbumsMap ? artistToAlbumsMap.get(currentArtist) : null; const tracks = albumToTracksMap ? albumToTracksMap.get(subscope) : null; if (tracks) { // randomize the order of jfIds from the current album queuedJfIds = shuffledCopy(tracks.map((track) => track.JfId)); } break; } } if (queuedJfIds.length > 0) { callAPI("POST", cfg.PRIV_BASE_URL + "/Sessions/" + jfSessionId + "/Playing?playCommand=PlayNow&itemIds=" + queuedJfIds.join(','), null); } return; // no menu navigation } // navigation let vanityList; let seqIdList = []; switch (scope) { case NavScope.NAV_SCOPE_SEARCH: { vanityList = Array.from(lookupMap.keys()); break; } case NavScope.NAV_SCOPE_ARTISTS: { if (subscope) { // allow filtering by starting character vanityList = Array.from(lookupMap.get(subscope).keys()); } else { vanityList = Array.from(lookupMap.values()).flatMap((artistMap) => Array.from(artistMap.keys())); } break; } case NavScope.NAV_SCOPE_ALBUMS: { currentArtist = subscope; const artistToAlbumsMap = lookupMap.get(Array.from(subscope)[0].toUpperCase()); vanityList = Array.from(artistToAlbumsMap.get(subscope).keys()); break; } case NavScope.NAV_SCOPE_ALBUM_TRACKS: { const albumToTracksMap = lookupMap.get(Array.from(currentArtist)[0].toUpperCase()).get(currentArtist); vanityList = albumToTracksMap.get(subscope).map((track) => track.TrackName); const jfIdList = albumToTracksMap.get(subscope).map((track) => track.JfId); for (const jfId of jfIdList) { seqIdList.push(jfIdToSeqIdMap.get(jfId)); } break; } default: return; } const outDict = {}; for (let i = 0; i < vanityList.length; i++) { outDict[keys.IN_VANITY_LIST + i] = vanityList[i]; if (scope === NavScope.NAV_SCOPE_ALBUM_TRACKS) { outDict[keys.IN_SEQ_ID_LIST + i] = seqIdList[i]; } } outDict[keys.BIDIR_MENU_NAV_SCOPE] = scope; Pebble.sendAppMessage(outDict); } }); ////////////////////// JELLYFIN API ////////////////////// function reportAPIFailure(statusCode, statusText) { console.log("[" + statusCode+"] API FAILURE: " + statusText); } function callAPI(method, endpoint, callback) { const xhr = new XMLHttpRequest(); xhr.onload = function () { switch (xhr.status) { case 200: case 204: if (callback) { callback(this.responseText); } return; default: reportAPIFailure(xhr.status, xhr.statusText); } }; xhr.onerror = function () { reportAPIFailure(xhr.status, xhr.statusText); }; xhr.ontimeout = function () { reportAPIFailure(xhr.status, xhr.statusText); }; xhr.open(method, endpoint, true); xhr.timeout = 8000; xhr.setRequestHeader("Accept", "application/json"); xhr.setRequestHeader("Authorization", 'MediaBrowser Token="' + cfg.PRIV_API_KEY + '"'); xhr.send(); }