neodot/.config/mpv/scripts/mpvDLNA/main.js
2023-11-29 21:44:29 -05:00

1406 lines
50 KiB
JavaScript

// mpvDLNA 3.3.1
"use strict";
mp.module_paths.push(mp.get_script_directory() + "/modules.js");
var Options = require('Options');
var Ass = require('AssFormat');
var SelectionMenu = require('SelectionMenu');
mp.module_paths.pop();
var DLNA_Node = function(name, id) {
this.name = name;
this.id = id;
this.url = null;
this.children = null;
this.info = null; // format is {start: episode#, end: episode#, description: string}
this.isPlaying = false;
this.type = "node";
};
var DLNA_Server = function(name, url) {
this.name = name;
this.id = "0"
this.url = url;
this.children = null;
this.type = "server"
};
// Helper function to remove first element and trailing newlines
var removeNL = function(sp) {
for (var i = 0; i < sp.length; i++) {
var s = sp[i]
if (s[s.length - 1] == "\r") {
s = s.slice(0, -1);
}
if (s[s.length - 1] == "\n") {
s = s.slice(0, -1);
}
sp[i] = s;
}
if (!sp[0] || !sp[0].length) {
sp.shift();
}
return sp;
};
// Class to browse DLNA servers
var DLNA_Browser = function(options) {
options = options || {};
// --------------------------
this.showHelpHint = typeof options.showHelpHint === 'boolean' ?
options.showHelpHint : true;
this.descriptionSize = options.descriptionFontSize;
if (this.descriptionSize === null) {
this.descriptionSize = options.menuFontSize / 4.5;
}
this.menu = new SelectionMenu({ // Throws if bindings are illegal.
maxLines: options.maxLines,
menuFontSize: options.menuFontSize,
autoCloseDelay: options.autoCloseDelay,
keyRebindings: options.keyRebindings
});
this.menu.setMetadata({type:null});
this._registerCallbacks();
var self = this;
// Only use menu text colors while mpv is rendering in GUI mode (non-CLI).
this.menu.setUseTextColors(mp.get_property_bool('vo-configured'));
mp.observe_property('vo-configured', 'bool', function(name, value) {
self.menu.setUseTextColors(value);
});
// Determine how to call python
this.python = null;
var versions = ["python", "python3"];
// If the .conf file specifies an option, test it first
if (options.python_version) {
versions.unshift(options.python_version);
}
// Test each option
for (var i = 0; i < versions.length; i++) {
var result = mp.command_native({
name: "subprocess",
playback_only: false,
capture_stdout: true,
capture_stderr: true,
args : [versions[i], mp.get_script_directory()+"/mpvDLNA.py", "-v"]
});
if (result.status != 0) {
mp.msg.debug("calling python as " + versions[i] + " errored with: " + result.stderr);
} else {
this.python = versions[i];
break;
}
}
// None of the options worked, throw an error
if (this.python == null) {
throw new Error("Unable to find a correctly configured python call: \n \
in the following options: " + versions +
"\n Please add the name of your python install to the .conf file \n \
using the format: python_version=python \n \
or run mpv with the --msg-level=mpvDLNA=trace argument to see the errors");
}
// How long to spend searching for DLNA servers
this.timeout = options.timeout
// How many nodes to fetch from the DLNA server when making a request
this.count = options.count
// list of the parents of the current node.
// The first element represents the server we are currently browsing
// The last element represents the node we are currently on
this.parents = [];
// List of titles to combine to get the title of the menu
this.titles = [];
this.current_folder = [];
// determine if we need to scan for DLNA servers next time the browser opens
this.scan = true;
this.servers = [];
// handle servers listed in the config file
for (var i = 0; i < options.serverNames.length; i++) {
this.servers.push(new DLNA_Server(options.serverNames[i], options.serverAddrs[i]));
}
if (this.servers.length != 0) {
this.menu.title = "Servers";
this.current_folder = this.servers;
this.menu.setOptions(this.servers, 0);
this.scan = false;
}
// list of wake on lan mac addresses
this.mac_addresses = options.macAddresses;
// List of nodes added to playlist. Should mirror the MPV internal playlist.
// This is necessary because in certain edge cases if a user were to be playing
// an episode while browsing with the DLNA browser and left the folder that the
// current episodes were in then we wouldn't be able to figure out where the
// now playing indicator should be
this.playlist = [];
this.playingUrl = null;
// Typing functionality
this.typing_controls = {
"ESC" : function(self){ self.toggle_typing() },
"ENTER" : function(self){ self.typing_parse() },
"LEFT" : function(self){ self.typing_action("left") },
"RIGHT" : function(self){ self.typing_action("right") },
"DOWN" : function(self){ self.typing_action("down") },
"UP" : function(self){ self.typing_action("up") },
"BS" : function(self){ self.typing_action("backspace") },
"CTRL+BS" : function(self){ self.typing_action("ctrl+backspace") },
"DEL" : function(self){ self.typing_action("delete") },
"SPACE" : function(self){ self.typing_action(" ") },
"TAB" : function(self){ self.typing_action("tab") }
};
this.typing_keys = [];
for (var i = 33; i <= 126; i++) {
this.typing_keys.push(String.fromCharCode(i));
}
this.typing_mode = "text";
this.typing_active = false;
this.typing_position = 0;
this.typing_text = "";
this.typing_output = "";
this.autocomplete = [];
this.selected_auto = {id: 0, full: ""};
this.commands = {
"scan" : { func: function(self){ self.menu.showMessage("Scanning");
self.findDLNAServers();
self.menu.showMessage("Scan Complete"); },
args: [],
text: false,
output: false},
"cd" : { func: function(self, file) { self.command_cd(file); },
args: [],
text: true,
output: false},
"text" : { func: function(self, file){ self.typing_mode = "text";
self.typing_text = "";
self.typing_position = 0;
self.typing_active = true;
self.typing_action(""); },
args: [],
text: false,
output: false},
"info" : { func: function(self, file){ var info = self.command_info(file);
if (info === null) {this.typing_output = "No Information";}
else{
self.typing_output = "Episode Number: "+info.start;
if (info.start != info.end) {self.typing_output += "-"+info.end;}
self.typing_output += Ass.size(self.descriptionSize, true)+"\nDescription: "+info.description;
}},
args: [],
text: true,
output: true},
"ep" : { func: function(self, args, file){ self.command_ep(args, file); },
args: ["Episode"],
text: true,
output: true},
"pep" : { func: function(self, args, file){ var result = self.command_ep(args, file);
if (result) self.select(self.menu.getSelectedItem()) },
args: ["Episode"],
text: true,
output: true},
"wake" : { func: function(self, args){ self.command_wake(args); },
args: ["Mac Address"],
auto: function(self, index){ return self.mac_addresses; },
text: false,
output: true},
};
this.command_list = Object.keys(this.commands);
// String key of the current command
this.command = null;
// List of already typed command arguments
this.arguments = [];
// List of unfinished typed command argument
this.typing_argument= "";
this.result_displayed = true;
// Send startup MAC Address wake on lan packets
options.startupMacAddresses.forEach(function(addr) { self.command_wake([addr]) });
};
DLNA_Browser.prototype.findDLNAServers = function() {
this.scan = false;
this.menu.title = "Scanning for DLNA Servers";
this.menu.renderMenu("", 1);
mp.msg.info("scanning for dlna servers");
this.servers = [];
// Increase the timeout if you have trouble finding a DLNA server that you know is working
var result = mp.command_native({
name: "subprocess",
playback_only: false,
capture_stdout: true,
capture_stderr: true,
args : [this.python, mp.get_script_directory()+"/mpvDLNA.py", "-l", this.timeout]
});
mp.msg.debug("mpvDLNA.py -l: " + result.stderr);
// Get the output, delete the first element if empty, and remove trailing newlines
var sp = removeNL(result.stdout.split("\n"));
for (var i = 0; i < sp.length; i=i+3) {
var server = new DLNA_Server(sp[i], sp[i+1]);
this.servers.push(server);
}
this.menu.title = "Servers";
this.parents = [];
this.current_folder = this.servers;
this.menu.setOptions(this.servers, 0);
this.menu.renderMenu("", 1);
};
DLNA_Browser.prototype.toggle = function() {
// Toggle the menu display state.
if (this.menu.menuActive) {
this.menu.hideMenu();
} else {
this.menu.showMenu();
// Determine if we need to scan for DLNA servers
if (this.scan) {
this.menu.title = "Scanning for DLNA Servers";
this.findDLNAServers();
}
}
};
// starts typing and sets mode to either "text" or "command"
DLNA_Browser.prototype.toggle_typing = function(mode) {
if (!this.typing_active) {
// if mode command is invalid just leave it on what it was before
if (mode == "text" || mode == "command") {
this.typing_mode = mode;
}
var self = this;
Object.keys(this.typing_controls).forEach( function(key) {
mp.add_forced_key_binding(key, "typing_"+key, function(){self.typing_controls[key](self)}, {repeatable:true})
});
this.typing_keys.forEach( function(key) {
mp.add_forced_key_binding(key, "typing_"+key, function(){self.typing_action(key)}, {repeatable:true})
});
this.typing_text = "";
this.typing_position = 0;
this.typing_active = true;
this.menu.showTyping();
this.typing_action("");
} else {
this.typing_active=false;
Object.keys(this.typing_controls).forEach( function(key) {
mp.remove_key_binding("typing_"+key);
});
this.typing_keys.forEach( function(key) {
mp.remove_key_binding("typing_"+key);
});
this.menu.hideTyping();
}
};
DLNA_Browser.prototype.typing_action = function(key) {
var tabbing = false;
if (key.length == 1){
// "\" does not play nicely with the formatting characters in the osd message
if (key != "\\") {
this.typing_text = this.typing_text.slice(0, this.typing_position)
+ key + this.typing_text.slice(this.typing_position);
this.typing_position+=1;
}
} else if (key == "backspace") {
// can't backspace if at the start of the line
var removed = "";
if (this.typing_position) {
removed = this.typing_text.slice(this.typing_position-1, this.typing_position);
this.typing_text = this.typing_text.slice(0, this.typing_position-1)
+ this.typing_text.slice(this.typing_position);
this.typing_position-= 1;
}
if (this.typing_mode == "command" && this.command != null) {
// The backspace effected the command
if (this.typing_position <= this.command.length) {
this.command = null;
this.typing_output = "";
// The backspace effected an argument
} else if (removed == " "){
var arg_lengths = 0;
this.arguments.forEach(function(arg){arg_lengths+=arg.length+1});
if (this.typing_position <= this.command.length + arg_lengths + 1) {
this.arguments.pop();
}
}
}
} else if (key == "ctrl+backspace") {
// May change this to delete the relevant filename/argument/command later
// Right now it just clears all text
this.typing_position = 0;
this.typing_text = "";
if (this.typing_mode == "command") {
this.command = null;
this.arguments = [];
this.typing_argument = "";
}
} else if (key == "delete") {
this.typing_text = this.typing_text.slice(0, this.typing_position)
+ this.typing_text.slice(this.typing_position+1);
if (this.typing_mode == "command" && this.command != null) {
if (this.typing_position <= this.command.length) {
this.command = null;
// Because we autoadd a space when completing a command and because
// using delete means the cursor is not next to it, the space becomes
// almost impossible to find. I wrote this code and still thought it
// was a bug when the invisible space character supressed the autocorrect
// Much easier for users to just not have to deal with it
if (this.typing_text[this.typing_text.length-1] == " ") {
this.typing_text = this.typing_text.slice(0, -1);
}
}
}
} else if (key == "right") {
this.typing_position += 1;
if (this.typing_position > this.typing_text.length) {
this.typing_position = 0;
}
} else if (key == "left") {
this.typing_position -= 1;
if (this.typing_position < 0) {
this.typing_position = this.typing_text.length;
}
} else if (key == "tab" || key == "down") {
tabbing = true;
this.selected_auto.id++;
if (this.selected_auto.id >= this.autocomplete.length) {
this.selected_auto.id = 0;
}
if (this.autocomplete.length) {
this.selected_auto.full = this.autocomplete[this.selected_auto.id].full;
}
} else if (key == "up") {
tabbing = true;
this.selected_auto.id--;
if (this.selected_auto.id < 0) {
this.selected_auto.id = this.autocomplete.length - 1;
}
if (this.autocomplete.length) {
this.selected_auto.full = this.autocomplete[this.selected_auto.id].full;
}
} else if (key == "clear"){
this.typing_text = "";
this.typing_position = 0;
this.autocomplete = [];
this.selected_auto = {id: 0, full: ""};
if (this.result_displayed) {
this.typing_output = "";
} else {
this.result_displayed = true;
}
}
var message = "";
message += Ass.white(true) + this.typing_text.slice(0, this.typing_position);
message += Ass.yellow(true) + "|";
message += Ass.white(true) + this.typing_text.slice(this.typing_position);
// Use command mode autocorrect.
if (this.typing_mode == "command") {
// Look for a valid command
if (this.command == null) {
this.arguments = [];
// Check if the first piece of the input is a valid command
if (this.typing_text.split(" ").length > 1) {
var search = this.typing_text.split(" ")[0];
for (var i = 0; i < this.command_list.length; i++) {
if (this.command_list[i].toUpperCase() == search.toUpperCase()) {
this.command = this.command_list[i];
break;
}
}
// Otherwise try to autocomplete the command
} else {
message = this.autocomplete_command(this.typing_text, message, tabbing, this.command_list);
}
}
// Have a valid command, autocomplete the arguments
if (this.command){
// Let the user type arguments
if (this.arguments.length < this.commands[this.command].args.length) {
this.arguments = this.typing_text.split(" ").slice(1);
this.typing_argument = this.arguments.pop();
if (this.commands[this.command].auto != null) {
var arg_lengths = 0;
this.arguments.forEach(function(arg){arg_lengths+=arg.length+1});
var argument = this.typing_text.slice(this.command.length + arg_lengths + 1);
var index = message.split(" ").slice(0,-1).join(" ").length + 1
var msg = message.slice(index);
message = message.slice(0, index) + this.autocomplete_command(argument, msg, tabbing, this.commands[this.command].auto(this, this.arguments.length-1));
}
}
// Display a hint about what kind of argument to enter
if (this.arguments.length < this.commands[this.command].args.length) {
this.typing_output = "Argument: " + this.commands[this.command].args[this.arguments.length];
} else if (this.typing_output.split(":")[0] == "Argument"){
this.typing_output = "";
}
// Have all the arguments, autocomplete the file
if (this.arguments.length == this.commands[this.command].args.length &&
this.commands[this.command].text){
var arg_lengths = 0;
this.arguments.forEach(function(arg){arg_lengths+=arg.length+1});
var argument = this.typing_text.slice(this.command.length + arg_lengths + 1);
this.typing_argument = "";
var index = message.split(" ").slice(0,-1).join(" ").length + 1
var msg = message.slice(index);
message = message.slice(0, index) + this.autocomplete_text(argument, msg, tabbing);
}
}
message = "$ " + message;
// Use text mode autocorrect.
} else if (this.typing_mode == "text") {
message = this.autocomplete_text(this.typing_text, message, tabbing);
}
message = Ass.startSeq(true) + message;
message += "\n" + Ass.alpha("00") + this.typing_output;
message += Ass.stopSeq(true);
this.menu.typingText = message;
this.menu._renderActiveText();
};
// Try to find a valid command or folder
DLNA_Browser.prototype.typing_parse = function() {
var success = false;
// Planned Commands (more to come)
// search - Either query the DLNA server if thats possible or just manually search
// Y cd - exactly the same as what text mode does now
// N play - this has been replaced with just trying to cd into the media file
// Y ep - find episode by number (maybe have option for absolute episode number instead of just its place in a season)
// pep - ep but starts playback
// Y info - query DLNA server for metadata (For some reason my DLNA server only gives metadata for episodes, not seasons\shows)
// Y text - switch to text input mode
if (this.typing_mode == "command") {
// This flag is used to make sure we don't accidentally autocomplete the command
// and the arguments in a single enter keystroke
var text_input = false;
if (this.command == null) {
text_input = true;
if (this.autocomplete.length != 0) {
this.command = this.selected_auto.full
this.typing_text = this.selected_auto.full + " ";
this.typing_position = this.typing_text.length;
// This variable is true if the command requires more information than just its name
text_input = this.commands[this.command].text || this.commands[this.command].args.length > 0;
}
}
if (this.command != null && !text_input) {
var cmd = this.commands[this.command];
if (cmd.text) {
// We have all the arguments and file text needed for the command
if (cmd.args.length == this.arguments.length && this.autocomplete.length != 0) {
mp.msg.trace("Calling " + this.command + " with args: " + this.arguments);
if (this.arguments.length > 0) {
cmd.func(this, this.arguments, this.selected_auto.full);
} else {
cmd.func(this, this.selected_auto.full);
}
this.result_displayed = !cmd.output;
success = true;
}
} else {
// Command only needs its name
if (cmd.args.length == 0) {
mp.msg.trace("Calling " + this.command + " with no args");
cmd.func(this);
this.result_displayed = !cmd.output;
success = true;
// We already have all but the last argument
} else if (this.arguments.length == cmd.args.length - 1){
// Autocomplete the last argument
if (this.autocomplete.length != 0) {
this.arguments.push(this.selected_auto.full)
// Can't autocomplete the last argument, use what the user entered
} else {
this.arguments.push(this.typing_argument)
}
mp.msg.trace("Calling " + this.command + " with args: " + this.arguments);
cmd.func(this, this.arguments);
this.result_displayed = !cmd.output;
success = true;
// Not enough arguments, autocomplete the one we are on
} else {
if (this.autocomplete.length != 0) {
if (this.typing_argument.length != 0) {
this.typing_text = this.typing_text.slice(0, -this.typing_argument.length)
}
this.typing_text += this.selected_auto.full + " ";
this.typing_position = this.typing_text.length;
}
}
}
}
} else if (this.typing_mode == "text") {
success = this.command_cd(this.selected_auto.full);
}
if (success) {
this.command = null;
this.typing_action("clear");
} else {
// Rescan for autocomplete
this.typing_action("");
}
};
// Works for commands and arguments
DLNA_Browser.prototype.autocomplete_command = function(text, message, tabbing, options) {
// find new autocomplete options only if we are actually typing
if (!tabbing) {
this.autocomplete = [];
if (this.options === null || (text == "" && this.selected_auto.full=="")) {
this.selected_auto = {id: null, full: ""};
return message;
}
for (var i = 0; i < options.length; i++) {
var index = options[i].toUpperCase().indexOf(text.toUpperCase());
if (index != -1) {
this.autocomplete.push({
pre: options[i].slice(0, index),
post: options[i].slice(index + text.length),
full: options[i],
sindex: index,
findex: i
});
}
}
// Prefer the search term appearing as soon in the text as possible
this.autocomplete.sort(function(a, b) {
return a.sindex == b.sindex ? a.findex - b.findex : a.sindex-b.sindex;
});
}
if (this.autocomplete.length > 0) {
var search = this.selected_auto.full;
// Prefer the earliest option in the list
this.selected_auto = {
pre: this.autocomplete[0].pre,
post: this.autocomplete[0].post,
full: this.autocomplete[0].full,
sindex: this.autocomplete[0].sindex,
findex: this.autocomplete[0].findex,
id: 0
}; // have to break this out or you get crazy referencing issues
for (var i = 0; i < this.autocomplete.length; i++) {
if (search == this.autocomplete[i].full) {
this.selected_auto = {
pre: this.autocomplete[i].pre,
post: this.autocomplete[i].post,
full: this.autocomplete[i].full,
sindex: this.autocomplete[i].sindex,
findex: this.autocomplete[i].findex,
id: i
}; // have to break this out or you get crazy referencing issues
break;
}
}
// Move the actively selected option to the front of the list so entries are
// sorted by how close to the front of the string the search term is
if (!tabbing) {
this.autocomplete = [this.autocomplete[this.selected_auto.id]].concat(
this.autocomplete.slice(0,this.selected_auto.id),
this.autocomplete.slice(this.selected_auto.id+1));
this.selected_auto = {
pre: this.autocomplete[0].pre,
post: this.autocomplete[0].post,
full: this.autocomplete[0].full,
sindex: this.autocomplete[0].sindex,
findex: this.autocomplete[0].findex,
id: 0
}; // have to break this out or you get crazy referencing issues
}
message = Ass.alpha("DDDD6E") + this.selected_auto.pre
+ Ass.alpha("00") + message + Ass.alpha("DDDD6E") + this.selected_auto.post;
} else {
this.selected_auto = {id: 0, full: ""};
}
return message;
}
DLNA_Browser.prototype.autocomplete_text = function(text, message, tabbing) {
// find new autocomplete options only if we are actually typing
if (!tabbing) {
this.autocomplete = [];
// add ".." to the list of autocomplete options
var options = this.current_folder.concat({name: ".."});
for (var i = 0; i < options.length; i++) {
var item = options[i];
var index = item.name.toUpperCase().indexOf(text.toUpperCase());
if ((item.children == null || item.children.length != 0) && index != -1) {
this.autocomplete.push({
pre: item.name.slice(0, index),
post: item.name.slice(index + text.length),
full: item.name,
sindex: index,
findex: i
});
}
}
// Prefer the search term appearing as soon in the text as possible
this.autocomplete.sort(function(a, b) {
return a.sindex == b.sindex ? a.findex - b.findex : a.sindex-b.sindex;
});
}
if (this.autocomplete.length > 0) {
var search = this.selected_auto.full;
this.selected_auto = {
pre: this.autocomplete[0].pre,
post: this.autocomplete[0].post,
full: this.autocomplete[0].full,
sindex: this.autocomplete[0].sindex,
findex: this.autocomplete[0].findex,
id: 0
}; // have to break this out or you get crazy referencing issues
for (var i = 0; i < this.autocomplete.length; i++) {
if (search == this.autocomplete[i].full) {
this.selected_auto = {
pre: this.autocomplete[i].pre,
post: this.autocomplete[i].post,
full: this.autocomplete[i].full,
sindex: this.autocomplete[i].sindex,
findex: this.autocomplete[i].findex,
id: i
}; // have to break this out or you get crazy referencing issues
break;
}
}
// Move the actively selected option to the front of the list so entries are
// sorted by how close to the front of the string the search term is
if (!tabbing) {
this.autocomplete = [this.autocomplete[this.selected_auto.id]].concat(
this.autocomplete.slice(0,this.selected_auto.id),
this.autocomplete.slice(this.selected_auto.id+1));
this.selected_auto = {
pre: this.autocomplete[0].pre,
post: this.autocomplete[0].post,
full: this.autocomplete[0].full,
sindex: this.autocomplete[0].sindex,
findex: this.autocomplete[0].findex,
id: 0
}; // have to break this out or you get crazy referencing issues
}
// Update the menu selection to match
this.menu.selectionIdx = this.selected_auto.findex;
this.menu.renderMenu("", 1);
message = Ass.alpha("DDDD6E") + this.selected_auto.pre
+ Ass.alpha("00") + message + Ass.alpha("DDDD6E") + this.selected_auto.post;
} else {
this.selected_auto = {id: 0, full: ""};
}
return message;
}
DLNA_Browser.prototype.command_cd = function(text) {
var success = false;
if (text == "..") {
this.back();
success = true;
}else {
for (var i = 0; i < this.current_folder.length; i++) {
var item = this.current_folder[i];
if (text == item.name) {
success = this.select(item);
}
}
}
return success
}
DLNA_Browser.prototype.command_info = function(text) {
if (text == "..") {
return null;
}
var selection = null;
for (var i = 0; i < this.current_folder.length; i++) {
var item = this.current_folder[i];
if (text == item.name) {
selection = item;
}
}
return this.info(selection);
}
DLNA_Browser.prototype.command_ep = function(args, text) {
if (text == "..") {
return false;
}
var selection = null;
for (var i = 0; i < this.current_folder.length; i++) {
var item = this.current_folder[i];
if (text == item.name) {
selection = item;
}
}
if (!selection) {
return false;
}
var target = parseInt(args[0]);
var episode = 0;
selection.children = this.getChildren(selection);
for (var i = 0; i < selection.children.length; i++) {
this.typing_output = "Scanning: "+ selection.children[i].name + ", reached E" + episode;
this.result_displayed = false;
this.typing_action("");
var info = this.info(selection.children[i]);
if (info === null) {
return false; // can't get enough information to find the episode
} else if (isNaN(info.end)) {
continue; // Maybe need to find a better check for this
}
if (target <= episode + info.end) {
// Season based episode# target
var s_target = target - episode;
selection.children[i].children = this.getChildren(selection.children[i]);
for (var j = 0; j < selection.children[i].children.length; j++) {
this.typing_output = "Scanning: "+ selection.children[i].name + ", reached E" + episode;
this.result_displayed = false;
this.typing_action("");
episode++;
var episode_info = this.info(selection.children[i].children[j]);
if (episode_info === null) {
continue;
}
// episode contains target episode
if (episode_info.start <= s_target && s_target <= episode_info.end) {
this.select(selection.children[i]);
this.menu.selectionIdx = j;
this.menu.renderMenu("", 1);
// Make the output look nice
var E = episode_info.start;
if (E < 10) {
E = "0"+E;
}
if (episode_info.start != episode_info.end) {
if (episode_info.end < 10) {
E += "0";
}
E +="-E"+episode_info.end;
}
if (target < 10) {
target = "0"+target;
}
this.typing_output = selection.name+" E"+target+" = "+selection.children[i].name+" E"+E;
return true;
}
}
break;
}
episode += info.end;
}
if (target < 10) {
target = "0"+target;
}
this.typing_output = Ass.color("FF0000", true) + "Failed to find "+selection.name+" E"+target
return false;
}
DLNA_Browser.prototype.command_wake = function(args) {
if (args[0] === null) {
this.typing_output = Ass.color("FF0000", true) + "MAC Address cannot be null";
return;
}
var result = mp.command_native({
name: "subprocess",
playback_only: false,
capture_stdout: true,
capture_stderr: true,
args : [this.python, mp.get_script_directory()+"/mpvDLNA.py", "-w", args[0]]
});
mp.msg.debug("mpvDLNA.py -w: " + result.stderr);
// Get the output, delete the first element if empty, and remove trailing newlines
var sp = removeNL(result.stdout.split("\n"));
if (sp[0] == "packet sent") {
this.typing_output = "Packet Sent";
} else if (sp[0] == "import failed"){
this.typing_output = Ass.color("FF0000", true) + "wakeonlan python package not installed";
} else {
this.typing_output = Ass.color("FF0000", true) + "unspecified error";
}
}
// This function adds the previous and next episodes to the playlist,
// changes the window title to the current episode title, and
// updates the now playing indicator
DLNA_Browser.prototype.on_file_load = function() {
// DLNA isn't being used
if (this.playlist.length == 0) {
return;
}
mp.msg.trace("on_file_load");
var p_index = mp.get_property_number("playlist-playing-pos", 1);
var playlist = mp.get_property_native("playlist", {});
var episode_number = null
for (var i = 0; i < this.playlist.length; i++) {
if (this.playlist[i].url == playlist[p_index].filename) {
episode_number = i;
break;
}
};
if (episode_number === null) {
mp.msg.warn("The DLNA playlist is not properly synced with the internal MPV playlist");
return
}
var episode = this.playlist[episode_number];
var folder = episode.folder.children; // The code below is very confusing if you forget that folder != episode.folder
// Update the now playing indicator and rerender the menu if necessary
folder[episode.id].isPlaying = true;
this.menu.renderMenu("", 1);
// Set the title to match the current episode
mp.msg.trace("setting title to: " + episode.folder.name + ": " + folder[episode.id].name);
mp.set_property("force-media-title", episode.folder.name + ": " + folder[episode.id].name);
this.playingUrl = episode.url;
// If there is a previous episode
if (episode.id - 1 >= 0) {
// and the playlist entry before this one either doesn't exist or isn't the previous episode
if (p_index-1 < 0 || playlist[p_index-1].filename != folder[episode.id-1].url) {
var prev = folder[episode.id-1];
mp.commandv("loadfile", prev.url, "append");
// Move the last element in the list (the one we just appended) to in front of the current episode
mp.commandv("playlist-move", playlist.length, p_index);
this.playlist.push({ folder: episode.folder,
id: episode.id-1,
url: prev.url
});
mp.msg.trace("Added previous episode to playlist");
}
}
// If there is a next episode
if (episode.id + 1 < folder.length) {
// and the playlist entry after this one either doesn't exist or isn't the next episode
if (p_index+1 >= playlist.length || playlist[p_index+1].filename != folder[episode.id+1].url) {
var next = folder[episode.id+1];
mp.commandv("loadfile", next.url, "append");
// Move the last element in the list (the one we just appended) to behind the current episode
mp.commandv("playlist-move", playlist.length, p_index+1);
this.playlist.push({ folder: episode.folder,
id: episode.id+1,
url: next.url
});
mp.msg.trace("Added next episode to playlist");
}
}
};
// Removes the now playing indicator
DLNA_Browser.prototype.on_file_end = function() {
// DLNA isn't being used
if (this.playlist.length == 0) {
return;
}
for (var i = 0; i < this.playlist.length; i++) {
if (this.playlist[i].url == this.playingUrl) {
var episode = this.playlist[i];
episode.folder.children[episode.id].isPlaying = false;
break;
}
};
};
DLNA_Browser.prototype.generateMenuTitle = function() {
this.menu.title=this.titles[this.titles.length-1];
// Already have the first element
for (var i = this.titles.length-2; i >= 0; i-- ) {
// Condense repeat menu titles by only using the more specific one
if (this.titles[i+1].indexOf(this.titles[i]) == -1) {
this.menu.title = this.titles[i] + " / " + this.menu.title;
if (this.menu.title.length > 90) {
this.menu.title = this.menu.title.slice(this.titles[i].length + 3, this.menu.title.length);
break;
}
}
}
};
DLNA_Browser.prototype.getChildren = function(selection) {
// This node has not been loaded before, fetch its children from the server
if (selection.children == null) {
var result = mp.command_native({
name: "subprocess",
playback_only: false,
capture_stdout: true,
capture_stderr: true,
args : [this.python, mp.get_script_directory()+"/mpvDLNA.py", "-b", this.parents[0].url, selection.id, this.count]
});
var categories = result.stdout.split("----")
var children = [];
// Check for items, then collections
for (var category = 0; category < 2; category++) {
// Get the output, delete the first element if empty, and remove trailing newlines
var sp = removeNL(categories[category].split("\n"));
// Tells us if we are getting item or container type data
var is_item = sp[0] == "items:";
var increase = (is_item ? 4 : 3);
var max_length = (is_item ? 1 : 2)
// The first 2 elements of sp are not useful here
for (var i = 2; i+max_length < sp.length; i=i+increase) {
var child = new DLNA_Node(sp[i], sp[i+1]);
if (is_item) {
child.url = sp[i+2];
}
children.push(child);
}
}
selection.children = children;
}
return selection.children
}
DLNA_Browser.prototype.select = function(selection) {
mp.msg.debug("selecting");
if (!selection) {
mp.msg.debug("selection was invalid");
}
if (selection.type == "server") {
mp.msg.debug("selecting server: " + selection.name);
this.parents = [selection];
this.titles = [];
} else if (selection.type == "node") {
mp.msg.debug("selecting node: " + selection.name);
if (selection.url === null) {
this.parents.push(selection)
} else {
mp.msg.info("Loading " + selection.name + ": " + selection.url);
mp.commandv("loadfile", selection.url, "replace");
// Clear the DLNA playlist of playing indicators and replace it with the new playlist
this.playlist.forEach(function(episode){episode.folder.children[episode.id].isPlaying = false});
this.playlist = [{folder: this.parents[this.parents.length-1],
id: this.parents[this.parents.length-1].children.indexOf(selection),
url: selection.url
}];
this.menu.hideMenu();
if (this.typing_active) {
this.toggle_typing();
}
return true;
}
} else {
// This should never happen
mp.msg.debug("selection type invalid");
return false;
}
// This will load the children if they haven't been already
this.parents[this.parents.length-1].children = this.getChildren(selection);
var success = false;
// If the selection has no children then don't bother moving to it
if (this.parents[this.parents.length-1].children.length == 0) {
mp.msg.debug("selection was empty");
this.parents.pop();
} else {
// Update the title and menu to the new selection
this.titles.push(selection.name);
this.generateMenuTitle();
mp.msg.trace("generated menu title");
this.current_folder = this.parents[this.parents.length-1].children;
mp.msg.trace("set current folder");
this.menu.setOptions(this.parents[this.parents.length-1].children, 0);
mp.msg.trace("set options");
success = true;
}
this.menu.renderMenu("", 1);
return success;
}
DLNA_Browser.prototype.info = function(selection) {
if (selection === null) {
return null;
}
// This node has not loaded its info before, fetch its metadata
if (selection.info == null) {
var result = mp.command_native({
name: "subprocess",
playback_only: false,
capture_stdout: true,
capture_stderr: true,
args : [this.python, mp.get_script_directory()+"/mpvDLNA.py", "-i", this.parents[0].url, selection.id, this.count]
});
mp.msg.debug("mpvDLNA.py -i: " + result.stderr);
// Get the output, delete the first element if empty, and remove trailing newlines
var sp = removeNL(result.stdout.split("\n"));
var info = {start: 1, end: 1, description: ""}
// Tells us if we are getting item or container type data
var is_item = sp[0] == "item";
if (is_item) {
// The first 2 elements of sp are not useful here, get the episode number
if (sp[2] != "No Episode Number") {
info.start = parseInt(sp[2]);
info.end = info.start;
// figure out if this is actually multiple episodes
var title_split = selection.name.split(" - ");
if (!/^\d+$/.test(title_split[0])) {
var ep_split = title_split[0].split("-");
info.end = parseInt(ep_split[1]);
}
}
} else {
selection.children = this.getChildren(selection);
if (selection.children.length != 0) {
var start_info = this.info(selection.children[0]);
var end_info = this.info(selection.children[selection.children.length-1])
info.start = start_info.start;
info.end = end_info.end;
}
}
// Everything else is the description (for some reason my DLNA server doesn't send descriptions for seasons/series, only episodes)
info.description = sp.slice(3).join("\n")
// Sometimes the description gets a little mangled but it seems like the issue is on the DLNA server side
selection.info = info;
}
return selection.info;
}
DLNA_Browser.prototype.back = function() {
this.parents.pop();
this.titles.pop();
if (this.parents.length == 0) {
this.current_folder = this.servers;
this.menu.setOptions(this.servers, 0);
this.menu.title = "Servers";
} else {
this.current_folder = this.parents[this.parents.length-1].children;
this.menu.setOptions(this.parents[this.parents.length-1].children, 0);
this.generateMenuTitle(this.titles[this.titles.length-1]);
}
this.menu.renderMenu("", 1);
}
DLNA_Browser.prototype._registerCallbacks = function() {
var self = this; // Inside the callbacks this will end up referring to menu instead of DLNA_Browser
this.menu.setCallbackMenuOpen(function() {
self.select(this.getSelectedItem());
});
this.menu.setCallbackMenuLeft(function() {
self.back();
});
this.menu.setCallbackMenuRight(function() {
self.select(this.getSelectedItem());
});
};
(function() {
// Read user configuration (uses defaults for any unconfigured options).
// * You can override these values via the configuration system, as follows:
// - Via permanent file: `<mpv config dir>/script-settings/Blackbox.conf`
// - Command override: `mpv --script-opts=mpvDLNA-server_names="{name1}+{name2}"`
// - Or by editing this file directly (not recommended, makes your updates harder).
var userConfig = new Options.advanced_options({
// How long to keep the menu open while you are idle.
// * (float/int) Ex: `10` (ten seconds), `0` (to disable autoclose).
auto_close: 0,
// Maximum number of file selection lines to show at a time.
// * (int) Ex: `20` (twenty lines). Cannot be lower than 3.
max_lines: 10,
// What font size to use for the menu text. Large sizes look the best.
// * (int) Ex: `42` (font size fourtytwo). Cannot be lower than 1.
font_size: 40,
description_font_size: 12,
// Whether to show the "[h for help]" hint on the first launch.
// * (bool) Ex: `yes` (enable) or `no` (disable).
help_hint: true,
server_names: '',
server_addrs: '',
mac_addresses: '',
startup_mac_addresses:'',
python_version: '',
timeout: '1',
count: '2000',
// Keybindings. You can bind any action to multiple keys simultaneously.
// * (string) Ex: `{up}`, `{up}+{shift+w}` or `{x}+{+}` (binds to "x" and the plus key).
// - Note that all "shift variants" MUST be specified as "shift+<key>".
'keys_menu_up': '{up}',
'keys_menu_down': '{down}',
'keys_menu_up_fast': '{shift+up}',
'keys_menu_down_fast': '{shift+down}',
'keys_menu_left': '{left}',
'keys_menu_right': '{right}',
'keys_menu_open': '{enter}',
'keys_menu_undo': '{bs}',
'keys_menu_help': '{h}',
'keys_menu_close': '{esc}'
});
// Create and initialize the media browser instance.
try {
var browser = new DLNA_Browser({
autoCloseDelay: userConfig.getValue('auto_close'),
maxLines: userConfig.getValue('max_lines'),
menuFontSize: userConfig.getValue('font_size'),
descriptionFontSize: userConfig.getValue('description_font_size'),
showHelpHint: userConfig.getValue('help_hint'),
serverNames: userConfig.getMultiValue('server_names'),
serverAddrs: userConfig.getMultiValue('server_addrs'),
macAddresses: userConfig.getMultiValue('mac_addresses'),
startupMacAddresses: userConfig.getMultiValue('startup_mac_addresses'),
python_version: userConfig.getValue('python_version'),
timeout: userConfig.getValue('timeout'),
count: userConfig.getValue('count'),
keyRebindings: {
'Menu-Up': userConfig.getMultiValue('keys_menu_up'),
'Menu-Down': userConfig.getMultiValue('keys_menu_down'),
'Menu-Up-Fast': userConfig.getMultiValue('keys_menu_up_fast'),
'Menu-Down-Fast': userConfig.getMultiValue('keys_menu_down_fast'),
'Menu-Left': userConfig.getMultiValue('keys_menu_left'),
'Menu-Right': userConfig.getMultiValue('keys_menu_right'),
'Menu-Open': userConfig.getMultiValue('keys_menu_open'),
'Menu-Undo': userConfig.getMultiValue('keys_menu_undo'),
'Menu-Help': userConfig.getMultiValue('keys_menu_help'),
'Menu-Close': userConfig.getMultiValue('keys_menu_close')
}
});
} catch (e) {
mp.msg.error('DLNA: '+e+'.');
mp.osd_message('DLNA: '+e.stack+'.', 30);
throw e; // Critical init error. Stop script execution.
}
// Provide the bindable mpv command which opens/cycles through the menu.
// * Bind this via input.conf: `ctrl+b script-binding Blackbox`.
// - To get to your favorites (if you've added some), press this key twice.
mp.add_key_binding(null, 'toggle_mpvDLNA', function() {
browser.toggle();
});
mp.add_key_binding(null, 'text_mpvDLNA', function(){
browser.toggle_typing("text");
})
mp.add_key_binding(null, 'command_mpvDLNA', function(){
browser.toggle_typing("command");
})
// Handle necessary changes when loading the next file
// such as adding the next and previous episodes to the playlist
// and updating the window title to match the episode title
mp.register_event("file-loaded", function() {
browser.on_file_load();
});
// Handle necessary changes when ending the current file
// such as marking it as no longer playing
mp.register_event("end-file", function() {
browser.on_file_end();
});
})();