Files
jellyPod/src/pkjs/index.js
T

418 lines
15 KiB
JavaScript

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();
}