This commit is contained in:
Tyler Perkins 2023-11-29 21:44:29 -05:00
parent f1416e6d57
commit c3a9f0dd1c
Signed by: tyler
GPG Key ID: 03B27509E17EFDC8
11 changed files with 2801 additions and 0 deletions

3
.config/mpv/input.conf Normal file
View File

@ -0,0 +1,3 @@
ctrl+b script-binding toggle_mpvDLNA
; script-binding text_mpvDLNA
: script-binding command_mpvDLNA

3
.config/mpv/mpv.conf Normal file
View File

@ -0,0 +1,3 @@
profile=gpu-hq
fs

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Matthew Woodward
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,102 @@
# mpvDLNA
A plugin to allow mpv to browse and watch content hosted on DLNA servers. Follow the Installation Instructions [here](https://github.com/chachmu/mpvDLNA#installation-instructions)
## Usage
mpvDLNA has two main methods of interacting with DLNA servers.
### Menu
Toggling the Menu will automatically scan for DLNA servers on the network if it does not have default servers defined in the config file. Once it has finished scanning it will display a list of servers. The arrow keys can be used to navigate the menu and selecting an entry in the list with the enter key (or right arrow key) will access it. Hitting the left arrow key will move back a folder. Attempting to access an empty folder will not enter the folder, instead it will turn the folder's name red. Accessing a media file will start playback while also adding playlist entries for the previous and next episodes so you can skip forwards and backwards without issues.
### Text/Command
Command mode opens a psuedo command prompt that allows for a variety of commands.
Both Command and Text mode support a fairly robust case insensitive autocompletion feature for commands (and certain types of arguments) that can also match the input to any part of the result although it sorts its recommendations by how close to the front of the string it found the input. For example, typing `re` might cause the autocompletion to first recommend "B***re***aking Bad" but tabbing through the suggestions you could find "The Wi***re***" (examples taken from the IMDb Most Popular TV Shows page).
File Input is essentially an argument that comes at the end of the command that is required to match an entry on the DLNA server through autocompletion or the command won't execute (Since the command has to be executed on an existing entry). Normal arguments will display a hint under the command input specifying what type of argument is expected. Calling `cd` on `..` will move back a folder.
| Command | Argument | File Input (Y/N) | Explanation |
| :-----: | :---------: | :-------------: | --------------------------------------------- |
| scan | N/A | N | Scan for DLNA servers |
| text | N/A | N | Switch to Text Mode |
| cd | N/A | Y | Access an item (or begin playback) |
| info | N/A | Y | Query the server for metadata |
| ep | Episode # | Y | Find an episode based on episode # ([see more](https://github.com/chachmu/mpvDLNA#more-on-ep)) |
| pep | Episode # | Y | Call ep and begin playback on the file |
| wake | MAC Address | N | Send a wake on lan packet |
Text Mode is similar to Command Mode except it is only for navigating the DLNA server. Essentially it is always running the `cd` command. This makes it ideal for quickly browsing through the DLNA server and starting playback on a specified file.
## Installation Instructions
This script requires an installation of [mpv.io](https://mpv.io) that was built to support javascript and lua.
1. Download the mpvDLNA folder either by cloning the repository or by downloading a zip of the [latest release](https://github.com/chachmu/mpvDLNA/releases) (make sure the folder is named `mpvDLNA`, the releases tend to add a version number to the end which can cause problems)
2. Put the mpvDLNA folder in the `/scripts` folder for mpv (`~/.config/mpv/scripts/` for Linux or macOS or `C:/Users/Username/AppData/Roaming/mpv/scripts/` for Windows).
3. Bind hotkeys (You can set your own but I personally like these keys) by adding these lines to `input.conf` (`~/.config/mpv/input.conf` for Linux or macOS or `C:/Users/Username/AppData/Roaming/mpv/input.conf` for Windows)
* Toggle the Menu: `ctrl+b script-binding toggle_mpvDLNA`
* Toggle Text Input: `; script-binding text_mpvDLNA`
* Toggle Command Input: `: script-binding command_mpvDLNA`
4. Install [uPnPclient](https://github.com/flyte/upnpclient) by running `pip install upnpclient`.
5. If you intend to use the wake on lan feature you will also need to install [pywakeonlan](https://github.com/remcohaszing/pywakeonlan) by running `pip install wakeonlan`
## main.js
This file contains the majority of the code including the gui. It handles storing and managing all of the information but due to limitations with the version of javascript supported by MPV ([MuJS](https://mujs.com)) it passes all DLNA communication through the mpvDLNA.py file. When starting playback of a file the DLNA url of the file is added to the internal MPV playlist and an event is triggered each time a file is loaded that adds the previous and next files to the playlist so you can skip forwards and backwards without issues.
More documentation on the specifics of how main.js works will be added at a later date.
## mpvDLNA.py
This python script supports a few simple operations to detect and browse DLNA servers. It is basically a simple wrapper of [flyte](https://github.com/flyte)'s [uPnPclient](https://github.com/flyte/upnpclient) library. This script also supports sending wake on lan packets using the [pywakeonlan](https://github.com/remcohaszing/pywakeonlan) module.
Supported Commands:
| Command | Explanation |
| ----------- | ----------------------------------------------------------------------------------------------- |
|-l, --list | Takes a timeout in seconds and outputs a list of DLNA Media Servers detected on the network |
|-b, --browse | Takes a DLNA server url and the id of a DLNA element and outputs that element's direct children |
|-i, --info | Takes a DLNA server url and the id of a DLNA element and outputs that element's metadata |
|-w, --wake | Takes a MAC address and attempts to send a wake on lan packet to it |
## Config File Example
The config file must be named mpvDLNA.conf and be placed in a folder called `/script-settings` in the same directory as the `/script` folder (_NOT INSIDE_).
```
# List of server names to automatically add without having to run scan
server_names={Name1}+{Name2}
# List of the server addresses, must correspond with server_names
server_addrs={Address1}+{Address2}
# List of mac addresses to autocomplete for wake on lan
mac_addresses={MAC_ID1}+{MAC_ID2}
# List of mac addresses to send a wake on lan packet to on startup
startup_mac_addresses={MAC_ID1}+{MAC_ID2}
# Font size of menu elements
font_size=35
# Font size of metadata description from the `info` command
description_font_size=10
# Command to use when calling python
python_version=python3
# Length of time to spend searching for DLNA servers (Try increasing this if you are having trouble finding your server, default is 1 second)
timeout = 20
# Number of nodes to fetch when making a request to the DLNA server (default is 2000)
count=5000
```
## More on `ep`
Given an absolute episode number (The episodes total position in the series instead of just its place in a season) and a series `ep` will try to scan through the show's various seasons to find the episode that matches.
This can be rather slow the first time it is called after opening MPV on longer series as mpvDLNA will have to fetch the information about every season preceding the one containing the correct episode and then every episode in that season preceding the episode itself. Successive calls for that series (up to the episode number loaded previously) should be very fast as mpvDLNA will have already stored the information it needs.
Can potentially find the wrong episode if there are missing seasons or incomplete seasons that it can't pull the final episode number from.
# Troubleshooting and Feature Requests
If you have any issues with mpvDLNA or have features you would like to request feel free to [make an issue](https://github.com/chachmu/mpvDLNA/issues/new/choose) or send me an email and I will try to take a look. As a warning I may not respond immediately or be willing to implement every feature but I always welcome feedback!

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,101 @@
/*
* ASSFORMAT.JS (MODULE)
*
* Version: 1.2.0
* Author: VideoPlayerCode
* URL: https://github.com/VideoPlayerCode/mpv-tools
* License: Apache License, Version 2.0
*/
/* jshint -W097 */
/* global mp, module, require */
'use strict';
var Utils = require('MicroUtils');
var Ass = {};
Ass._startSeq = mp.get_property_osd('osd-ass-cc/0');
Ass._stopSeq = mp.get_property_osd('osd-ass-cc/1');
Ass.startSeq = function(output)
{
return output === false ? '' : Ass._startSeq;
};
Ass.stopSeq = function(output)
{
return output === false ? '' : Ass._stopSeq;
};
Ass.esc = function(str, escape)
{
if (escape === false) // Conveniently disable escaping via the same call.
return str;
// Uses the same technique as mangle_ass() in mpv's osd_libass.c:
// - Treat backslashes as literal by inserting a U+2060 WORD JOINER after
// them so libass can't interpret the next char as an escape sequence.
// - Replace `{` with `\{` to avoid opening an ASS override block. There is
// no need to escape the `}` since it's printed literally when orphaned.
// - See: https://github.com/libass/libass/issues/194#issuecomment-351902555
return str.replace(/\\/g, '\\\u2060').replace(/\{/g, '\\{');
};
Ass.size = function(fontSize, output)
{
return output === false ? '' : '{\\fs'+fontSize+'}';
};
Ass.scale = function(scalePercent, output)
{
return output === false ? '' : '{\\fscx'+scalePercent+'\\fscy'+scalePercent+'}';
};
Ass.convertPercentToHex = function(percent, invertValue)
{
// Tip: Use with "invertValue" to convert input range 0.0 (invisible) - 1.0
// (fully visible) to hex range '00' (fully visible) - 'FF' (invisible), for
// use with the alpha() function in a logical manner for end-users.
if (typeof percent !== 'number' || percent < 0 || percent > 1)
throw 'Invalid percentage value (must be 0.0 - 1.0)';
return Utils.toHex(
Math.floor( // Invert range (optionally), and make into a 0-255 value.
255 * (invertValue ? 1 - percent : percent)
),
2 // Fixed-size: 2 bytes (00-FF), as needed for hex in ASS subtitles.
);
};
Ass.alpha = function(transparencyHex, output)
{
return output === false ? '' : '{\\alpha&H'+transparencyHex+'&}'; // 00-FF.
};
Ass.color = function(rgbHex, output)
{
return output === false ? '' : '{\\1c&H'+rgbHex.substring(4, 6)+rgbHex.substring(2, 4)+rgbHex.substring(0, 2)+'&}';
};
Ass.white = function(output)
{
return Ass.color('FFFFFF', output);
};
Ass.gray = function(output)
{
return Ass.color('909090', output);
};
Ass.yellow = function(output)
{
return Ass.color('FFFF90', output);
};
Ass.green = function(output)
{
return Ass.color('90FF90', output);
};
module.exports = Ass;

View File

@ -0,0 +1,181 @@
/*
* MICROUTILS.US (MODULE)
*
* Version: 1.3.0
* Author: VideoPlayerCode
* URL: https://github.com/VideoPlayerCode/mpv-tools
* License: Apache License, Version 2.0
*/
/* jshint -W097 */
/* global mp, module, require */
'use strict';
var Utils = {};
// NOTE: This is an implementation of a non-recursive quicksort, which doesn't
// risk any stack overflows. This function is necessary because of a MuJS <=
// 1.0.1 bug which causes a stack overflow when running its built-in sort() on
// any large array. See: https://github.com/ccxvii/mujs/issues/55
// Furthermore, this performs optimized case-insensitive sorting.
Utils.quickSort = function(arr, options)
{
options = options || {};
var i, sortRef,
caseInsensitive = !!options.caseInsensitive;
if (caseInsensitive) {
sortRef = arr.slice(0);
for (i = sortRef.length - 1; i >= 0; --i)
if (typeof sortRef[i] === 'string')
sortRef[i] = sortRef[i].toLowerCase();
return Utils.quickSort_Run(arr, sortRef);
}
return Utils.quickSort_Run(arr);
};
Utils.quickSort_Run = function(arr, sortRef)
{
if (arr.length <= 1)
return arr;
var hasSortRef = !!sortRef;
if (!hasSortRef)
sortRef = arr; // Use arr instead. Makes a direct reference (no copy).
if (arr.length !== sortRef.length)
throw 'Array and sort-reference length must be identical';
// Adapted from a great, public-domain C algorithm by Darel Rex Finley.
// Original implementation: http://alienryderflex.com/quicksort/
// Ported by VideoPlayerCode and extended to sort via a 2nd reference array,
// to allow sorting the main array by _any_ criteria via the 2nd array.
var refPiv, arrPiv, beg = [], end = [], stackMax = -1, stackPtr = 0, L, R;
beg.push(0); end.push(sortRef.length);
++stackMax; // Tracks highest available stack index.
while (stackPtr >= 0) {
L = beg[stackPtr]; R = end[stackPtr] - 1;
if (L < R) {
if (hasSortRef) // If we have a SEPARATE sort-ref, mirror actions!
arrPiv = arr[L];
refPiv = sortRef[L]; // Left-pivot is fastest, no MuJS math needed!
while (L < R) {
while (sortRef[R] >= refPiv && L < R) R--;
if (L < R) {
if (hasSortRef)
arr[L] = arr[R];
sortRef[L++] = sortRef[R];
}
while (sortRef[L] <= refPiv && L < R) L++;
if (L < R) {
if (hasSortRef)
arr[R] = arr[L];
sortRef[R--] = sortRef[L];
}
}
if (hasSortRef)
arr[L] = arrPiv;
sortRef[L] = refPiv;
if (stackPtr === stackMax) {
beg.push(0); end.push(0); // Grow stacks to fit next elem.
++stackMax;
}
beg[stackPtr + 1] = L + 1;
end[stackPtr + 1] = end[stackPtr];
end[stackPtr++] = L;
} else {
stackPtr--;
// NOTE: No need to shrink stack here. Size-reqs GROW until sorted!
// (Anyway, MuJS is slow at splice() and wastes time if we shrink.)
}
}
return arr;
};
Utils.isInt = function(value)
{
// Verify that the input is an integer (whole number).
return (typeof value !== 'number' || isNaN(value)) ?
false :
(value | 0) === value;
};
Utils._hexSymbols = [
'0', '1', '2', '3', '4', '5', '6', '7',
'8', '9', 'a', 'b', 'c', 'd', 'e', 'f'
];
Utils.toHex = function(num, outputLength)
{
// Generates a fixed-length output, and handles negative numbers properly.
var result = '';
while (outputLength--) {
result = Utils._hexSymbols[num & 0xF] + result;
num >>= 4;
}
return result;
};
Utils.shuffle = function(arr)
{
var m = arr.length, tmp, i;
while (m) { // While items remain to shuffle...
// Pick a remaining element...
i = Math.floor(Math.random() * m--);
// And swap it with the current element.
tmp = arr[m];
arr[m] = arr[i];
arr[i] = tmp;
}
return arr;
};
Utils.trim = function(str)
{
return str.replace(/(?:^\s+|\s+$)/g, ''); // Trim left and right whitespace.
};
Utils.ltrim = function(str)
{
return str.replace(/^\s+/, ''); // Trim left whitespace.
};
Utils.rtrim = function(str)
{
return str.replace(/\s+$/, ''); // Trim right whitespace.
};
Utils.dump = function(value)
{
mp.msg.error(JSON.stringify(value));
};
Utils.benchmarkStart = function(textLabel)
{
Utils.benchmarkTimestamp = mp.get_time();
Utils.benchmarkTextLabel = textLabel;
};
Utils.benchmarkEnd = function()
{
var now = mp.get_time(),
start = Utils.benchmarkTimestamp ? Utils.benchmarkTimestamp : now,
elapsed = now - start,
label = typeof Utils.benchmarkTextLabel === 'string' ? Utils.benchmarkTextLabel : '';
mp.msg.info('Time Elapsed (Benchmark'+(label.length ? ': '+label : '')+'): '+elapsed+' seconds.');
};
module.exports = Utils;

View File

@ -0,0 +1,206 @@
/*
* OPTIONS.JS (MODULE)
*
* Description: JavaScript implementation of mpv's Lua API's config file system,
* via "mp.options.read_options()". See official Lua docs for help.
* https://github.com/mpv-player/mpv/blob/master/DOCS/man/lua.rst#mpoptions-functions
* Version: 2.1.0
* Author: VideoPlayerCode
* URL: https://github.com/VideoPlayerCode/mpv-tools
* License: Apache License, Version 2.0
*/
/* jshint -W097 */
/* global mp, exports, require */
/* This is a slightly modified version of the Options module from VideoPlayerCode
that has been changed to better work with the mpvDLNA plugin.
Specifically it checks an additional location for the config file (/script-opts)
*/
'use strict';
var ScriptConfig = function(options, identifier)
{
if (!options)
throw 'Options table parameter is missing.';
this.options = options;
this.scriptName = typeof identifier === 'string' ? identifier : mp.get_script_name();
this.configFile = null;
// Converts string "val" to same primitive type as "destTypeVal".
var typeConv = function(destTypeVal, val)
{
switch (typeof destTypeVal) {
case 'object':
if (!Array.isArray(destTypeVal))
val = undefined; // Unknown "object" target variable.
else if (typeof val !== 'string')
val = String(val); // Target is array, so use string values.
break;
case 'string':
if (typeof val !== 'string')
val = String(val);
break;
case 'boolean':
if (val === 'yes')
val = true;
else if (val === 'no')
val = false;
else {
mp.msg.error('Error: Can\'t convert '+JSON.stringify(val)+' to boolean!');
val = undefined;
}
break;
case 'number':
var num = parseFloat(val);
if (!isNaN(num))
val = num;
else {
mp.msg.error('Error: Can\'t convert '+JSON.stringify(val)+' to number!');
val = undefined;
}
break;
default:
val = undefined;
}
return val;
};
// Find config file.
if (this.scriptName && this.scriptName.length) {
mp.msg.debug('Reading options for '+this.scriptName+'.');
this.configFile = mp.find_config_file('script-settings/'+this.scriptName+'.conf');
if (!this.configFile) // Try legacy settings location as fallback.
this.configFile = mp.find_config_file('script-opts/'+this.scriptName+'.conf');
if (!this.configFile) // Try legacy settings location as fallback.
this.configFile = mp.find_config_file('lua-settings/'+this.scriptName+'.conf');
}
// Read and parse configuration if found.
var i, len, pos, key, val, isArrayVal, convVal;
if (this.configFile && this.configFile.length) {
try {
var line, configLines = mp.utils.read_file(this.configFile).split(/[\r\n]+/);
for (i = 0, len = configLines.length; i < len; ++i) {
line = configLines[i].replace(/^\s+/, '');
if (!line.length || line.charAt(0) === '#')
continue;
pos = line.indexOf('=');
if (pos < 0) {
mp.msg.warn('"'+this.configFile+'": Ignoring malformatted config line "'+line.replace(/\s+$/, '')+'".');
continue;
}
key = line.substring(0, pos);
val = line.substring(pos + 1);
isArrayVal = false;
if ('[]' === line.substring(pos - 2, pos)) {
key = key.substring(0, key.length - 2);
isArrayVal = true;
}
if (this.options.hasOwnProperty(key)) {
convVal = typeConv(this.options[key], val);
if (typeof convVal !== 'undefined') {
if (Array.isArray(this.options[key])) {
if (isArrayVal)
this.options[key].push(convVal);
else
mp.msg.error('"'+this.configFile+'": Ignoring non-array value for array-based option key "'+key+'".');
}
else
this.options[key] = convVal;
}
else
mp.msg.error('"'+this.configFile+'": Unable to convert value "'+val+'" for key "'+key+'".');
}
else
mp.msg.warn('"'+this.configFile+'": Ignoring unknown key "'+key+'".');
}
} catch (e) {
mp.msg.error('Unable to read configuration file "'+this.configFile+'".');
}
}
else
mp.msg.verbose('Unable to find configuration file for '+this.scriptName+'.');
// Parse command-line options.
if (this.scriptName && this.scriptName.length) {
var cmdOpts = mp.get_property_native('options/script-opts'), rawOpt,
prefix = this.scriptName+'-', keyLen;
len = prefix.length;
for (rawOpt in cmdOpts) {
if (!cmdOpts.hasOwnProperty(rawOpt))
continue;
pos = rawOpt.indexOf(prefix);
if (pos !== 0)
continue;
key = rawOpt.substring(len);
keyLen = key.length;
isArrayVal = false;
if ('[]' === key.substring(keyLen - 2)) {
key = key.substring(0, keyLen - 2);
isArrayVal = true;
}
if (key.length && this.options.hasOwnProperty(key)) {
val = cmdOpts[rawOpt];
convVal = typeConv(this.options[key], val);
if (typeof convVal !== 'undefined') {
if (Array.isArray(this.options[key])) {
if (isArrayVal)
this.options[key].push(convVal);
else
mp.msg.error('script-opts: Ignoring non-array value for array-based option key "'+key+'".');
}
else
this.options[key] = convVal;
}
else
mp.msg.error('script-opts: Unable to convert value "'+val+'" for key "'+key+'".');
}
else
mp.msg.warn('script-opts: Ignoring unknown key "'+key+'".');
}
}
};
ScriptConfig.prototype.getValue = function(key)
{
if (!this.options.hasOwnProperty(key))
throw 'Invalid option "'+key+'"';
return this.options[key];
};
ScriptConfig.prototype.getMultiValue = function(key)
{
// Multi-value format: `{one}+{two}+{three}`.
var i, len,
val = this.getValue(key), // Throws.
result = [];
if (typeof val !== 'string')
throw 'Invalid non-string value in multi-value option "'+key+'"';
len = val.length;
if (len) {
if (val.charAt(0) !== '{' || val.charAt(len - 1) !== '}')
throw 'Missing surrounding "{}" brackets in multi-value option "'+key+'"';
val = val.substring(1, len - 1).split('}+{');
len = val.length;
for (i = 0; i < len; ++i) {
result.push(val[i]);
}
}
return result;
};
// Class `advanced_options()`: Offers extended features such as multi-values.
exports.advanced_options = ScriptConfig;
// Function `read_options()`: Behaves like Lua API (returns plain list of opts).
exports.read_options = function(table, identifier) {
// NOTE: "table" will be modified by reference, just as the Lua version.
var config = new ScriptConfig(table, identifier);
return config.options; // This is the same object as "table".
};

View File

@ -0,0 +1,3 @@
# This code is originally from [VideoPlayerCode](https://github.com/VideoPlayerCode)'s
# [mpv-tools](https://github.com/VideoPlayerCode/mpv-tools/) repository under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0.html).
## The SelectionMenu.js and Options.js files have been modified to better work with mpvDLNA but the other files are still in their original form.

View File

@ -0,0 +1,649 @@
/*
* SELECTIONMENU.JS (MODULE)
*
* Version: 1.3.1
* Author: VideoPlayerCode
* URL: https://github.com/VideoPlayerCode/mpv-tools
* License: Apache License, Version 2.0
*/
/* jshint -W097 */
/* global mp, module, require, setInterval, clearInterval, setTimeout, clearTimeout */
/* This is a modified version of the SelectionMenu module from VideoPlayerCode
that has been changed to better work with the mpvDLNA plugin.
Specifically it has additional support for:
Coloring empty folders red
Displaying an indicator on currently playing entries
Displaying a text input system
*/
'use strict';
var Ass = require('AssFormat'),
Utils = require('MicroUtils');
var SelectionMenu = function(settings)
{
settings = settings || {};
this.uniqueId = 'M'+String(mp.get_time_ms()).replace(/\./g, '').substring(3)+
Math.floor((100+(Math.random()*899)));
this.metadata = null;
this.title = 'No title';
this.options = [];
this.selectionIdx = 0;
this.cbMenuShow = typeof settings.cbMenuShow === 'function' ? settings.cbMenuShow : null;
this.cbMenuHide = typeof settings.cbMenuHide === 'function' ? settings.cbMenuHide : null;
this.cbMenuLeft = typeof settings.cbMenuLeft === 'function' ? settings.cbMenuLeft : null;
this.cbMenuRight = typeof settings.cbMenuRight === 'function' ? settings.cbMenuRight : null;
this.cbMenuOpen = typeof settings.cbMenuOpen === 'function' ? settings.cbMenuOpen : null;
this.cbMenuUndo = typeof settings.cbMenuUndo === 'function' ? settings.cbMenuUndo : null;
this.maxLines = typeof settings.maxLines === 'number' &&
settings.maxLines >= 3 ? Math.floor(settings.maxLines) : 10;
this.menuFontAlpha = Ass.convertPercentToHex( // Throws if invalid input.
(typeof settings.menuFontAlpha === 'number' &&
settings.menuFontAlpha >= 0 && settings.menuFontAlpha <= 1 ?
settings.menuFontAlpha : 1),
true // Invert input range so "1.0" is visible and "0.0" is invisible.
);
this.menuFontSize = typeof settings.menuFontSize === 'number' &&
settings.menuFontSize >= 1 ? Math.floor(settings.menuFontSize) : 40;
this.originalFontSize = null;
this.hasRegisteredKeys = false; // Also means that menu is active/open.
this.useTextColors = true;
this.currentMenuText = '';
this.typingText = '';
this.active = false; // Menu is visible
this.menuActive = false; // the menu portion of the UI is active
this.typingActive = false; // the typing portion of the UI is active
this.isShowingMessage = false;
this.currentMessageText = '';
this.menuInterval = null;
this.stopMessageTimeout = null;
this.autoCloseDelay = typeof settings.autoCloseDelay === 'number' &&
settings.autoCloseDelay >= 0 ? settings.autoCloseDelay : 5; // 0 = Off.
this.autoCloseActiveAt = 0;
this.keyBindings = { // Default keybindings.
'Menu-Up':{repeatable:true, keys:['up']},
'Menu-Down':{repeatable:true, keys:['down']},
'Menu-Up-Fast':{repeatable:true, keys:['shift+up']},
'Menu-Down-Fast':{repeatable:true, keys:['shift+down']},
'Menu-Left':{repeatable:true, keys:['left']},
'Menu-Right':{repeatable:false, keys:['right']},
'Menu-Open':{repeatable:false, keys:['enter']},
'Menu-Undo':{repeatable:false, keys:['bs']},
'Menu-Help':{repeatable:false, keys:['h']},
'Menu-Close':{repeatable:false, keys:['esc']}
};
// Apply custom rebinding overrides if provided.
// Format: `{'Menu-Open':['a','shift+b']}`
// Note that all "shift variants" MUST be specified as "shift+<key>".
var i, action, key, allKeys, erasedDefaults,
rebinds = settings.keyRebindings;
if (rebinds) {
for (action in rebinds) {
if (!rebinds.hasOwnProperty(action))
continue;
if (!this.keyBindings.hasOwnProperty(action))
throw 'Invalid menu action "'+action+'" in rebindings';
erasedDefaults = false;
allKeys = rebinds[action];
for (i = 0; i < allKeys.length; ++i) {
key = allKeys[i];
if (typeof key !== 'string')
throw 'Invalid non-string key ('+JSON.stringify(key)+') in custom rebindings';
key = key.toLowerCase(); // Unify case of all keys for de-dupe.
key = Utils.trim(key); // Trim whitespace.
if (!key.length)
continue;
if (!erasedDefaults) { // Erase default keys for this action.
erasedDefaults = true;
this.keyBindings[action].keys = [];
}
this.keyBindings[action].keys.push(key);
}
}
}
// Verify that no duplicate bindings exist for the same key.
var boundKeys = {};
for (action in this.keyBindings) {
if (!this.keyBindings.hasOwnProperty(action))
continue;
allKeys = this.keyBindings[action].keys;
for (i = 0; i < allKeys.length; ++i) {
key = allKeys[i];
if (boundKeys.hasOwnProperty(key))
throw 'Invalid duplicate menu bindings for key "'+key+'" (detected in action "'+action+'")';
boundKeys[key] = true;
}
}
};
SelectionMenu.prototype.setMetadata = function(metadata)
{
this.metadata = metadata;
};
SelectionMenu.prototype.getMetadata = function()
{
return this.metadata;
};
SelectionMenu.prototype.setTitle = function(newTitle)
{
if (typeof newTitle !== 'string')
throw 'setTitle: No title value provided';
this.title = newTitle;
};
SelectionMenu.prototype.setOptions = function(newOptions, initialSelectionIdx)
{
if (typeof newOptions === 'undefined')
throw 'setOptions: No options value provided';
this.options = newOptions;
this.selectionIdx = typeof initialSelectionIdx === 'number' &&
initialSelectionIdx >= 0 && initialSelectionIdx < newOptions.length ?
initialSelectionIdx : 0;
};
SelectionMenu.prototype.setCallbackMenuShow = function(newCbMenuShow)
{
this.cbMenuShow = typeof newCbMenuShow === 'function' ? newCbMenuShow : null;
};
SelectionMenu.prototype.setCallbackMenuHide = function(newCbMenuHide)
{
this.cbMenuHide = typeof newCbMenuHide === 'function' ? newCbMenuHide : null;
};
SelectionMenu.prototype.setCallbackMenuLeft = function(newCbMenuLeft)
{
this.cbMenuLeft = typeof newCbMenuLeft === 'function' ? newCbMenuLeft : null;
};
SelectionMenu.prototype.setCallbackMenuRight = function(newCbMenuRight)
{
this.cbMenuRight = typeof newCbMenuRight === 'function' ? newCbMenuRight : null;
};
SelectionMenu.prototype.setCallbackMenuOpen = function(newCbMenuOpen)
{
this.cbMenuOpen = typeof newCbMenuOpen === 'function' ? newCbMenuOpen : null;
};
SelectionMenu.prototype.setCallbackMenuUndo = function(newCbMenuUndo)
{
this.cbMenuUndo = typeof newCbMenuUndo === 'function' ? newCbMenuUndo : null;
};
SelectionMenu.prototype.setUseTextColors = function(value)
{
var hasChanged = this.useTextColors !== value;
this.useTextColors = !!value;
// Update text cache, and redraw menu if visible (otherwise don't show it).
if (hasChanged)
this.renderMenu(null, 1); // 1 = Only redraw if menu is onscreen.
};
SelectionMenu.prototype.isMenuActive = function()
{
return this.active;
};
SelectionMenu.prototype.getSelectedItem = function()
{
if (this.selectionIdx < 0 || this.selectionIdx >= this.options.length)
return '';
else
return this.options[this.selectionIdx];
};
SelectionMenu.prototype._processBindings = function(fnCb)
{
if (typeof fnCb !== 'function')
throw 'Missing callback for _processBindings';
var i, key, allKeys, action, identifier,
bindings = this.keyBindings;
for (action in bindings) {
if (!bindings.hasOwnProperty(action))
continue;
allKeys = bindings[action].keys;
for (i = 0; i < allKeys.length; ++i) {
key = allKeys[i];
identifier = this.uniqueId+'_'+action+'_'+key;
fnCb(
identifier, // Unique identifier for this binding.
action, // What action the key is assigned to trigger.
key, // What key.
bindings[action] // Details about this binding.
);
}
}
};
SelectionMenu.prototype._registerMenuKeys = function()
{
if (this.hasRegisteredKeys)
return;
// Necessary in order to preserve "this" in the called function, since mpv's
// callbacks don't receive "this" if the object's func is keybound directly.
var createFn = function(obj, fn) {
return function() {
obj._menuAction(fn);
};
};
var self = this;
this._processBindings(function(identifier, action, key, details) {
mp.add_forced_key_binding(
key, // What key.
identifier, // Unique identifier for the binding.
createFn(self, action), // Generate anonymous func to execute.
{repeatable:details.repeatable} // Extra options.
);
});
this.hasRegisteredKeys = true;
};
SelectionMenu.prototype._unregisterMenuKeys = function()
{
if (!this.hasRegisteredKeys)
return;
var self = this;
this._processBindings(function(identifier, action, key, details) {
mp.remove_key_binding(
identifier // Remove binding by its unique identifier.
);
});
this.hasRegisteredKeys = false;
};
SelectionMenu.prototype._menuAction = function(action)
{
if (this.isShowingMessage && action !== 'Menu-Close')
return; // Block everything except "close" while showing a message.
switch (action) {
case 'Menu-Up':
case 'Menu-Down':
case 'Menu-Up-Fast':
case 'Menu-Down-Fast':
var maxIdx = this.options.length - 1;
if (action === 'Menu-Up' || action === 'Menu-Up-Fast')
this.selectionIdx -= (action === 'Menu-Up-Fast' ? 10 : 1);
else
this.selectionIdx += (action === 'Menu-Down-Fast' ? 10 : 1);
// Handle wraparound in single-move mode, or clamp in fast-move mode.
if (this.selectionIdx < 0)
this.selectionIdx = (action === 'Menu-Up-Fast' ? 0 : maxIdx);
else if (this.selectionIdx > maxIdx)
this.selectionIdx = (action === 'Menu-Down-Fast' ? maxIdx : 0);
this.renderMenu();
break;
case 'Menu-Left':
case 'Menu-Right':
case 'Menu-Open':
case 'Menu-Undo':
var cbName = 'cb'+action.replace(/-/g, '');
if (typeof this[cbName] === 'function') {
// We don't know what the callback will do, and it may be slow, so
// we'll disable the menu's auto-close timeout while it runs.
this._disableAutoCloseTimeout(); // Soft-disable.
this[cbName](action);
}
break;
case 'Menu-Help':
// List all keybindings to help the user remember them.
var entry, entryTitle, allKeys,
c = this.useTextColors,
helpLines = 0,
helpString = Ass.startSeq(c)+Ass.alpha(this.menuFontAlpha, c),
bindings = this.keyBindings;
for (entry in bindings) {
if (!bindings.hasOwnProperty(entry))
continue;
allKeys = bindings[entry].keys;
if (!entry.match(/^Menu-/) || !allKeys || !allKeys.length)
continue;
entryTitle = entry.substring(5);
if (!entryTitle.length)
continue;
Utils.quickSort(allKeys, {caseInsensitive: true});
++helpLines;
helpString += Ass.yellow(c)+Ass.esc(entryTitle, c)+': '+
Ass.white(c)+Ass.esc('{'+allKeys.join('}, {')+'}', c)+'\n';
}
helpString += Ass.stopSeq(c);
if (!helpLines)
helpString = 'No help available.';
this.showMessage(helpString, 5000);
break;
case 'Menu-Close':
this.hideMenu();
break;
default:
mp.msg.error('Unknown menu action "'+action+'"');
return;
}
this._updateAutoCloseTimeout(); // Soft-update.
};
SelectionMenu.prototype._disableAutoCloseTimeout = function(forceLock)
{
this.autoCloseActiveAt = forceLock ? -2 : -1; // -2 = hard, -1 = soft.
};
SelectionMenu.prototype._updateAutoCloseTimeout = function(forceUnlock)
{
if (!forceUnlock && this.autoCloseActiveAt === -2)
return; // Do nothing while autoclose is locked in "disabled" mode.
this.autoCloseActiveAt = mp.get_time();
};
SelectionMenu.prototype._handleAutoClose = function()
{
if (this.autoCloseDelay <= 0 || this.autoCloseActiveAt <= -1) // -2 = hard, -1 = soft.
return; // Do nothing while autoclose is disabled (0) or locked (< 0).
var now = mp.get_time();
if (this.autoCloseActiveAt <= (now - this.autoCloseDelay))
this.hideMenu();
};
SelectionMenu.prototype._renderActiveText = function()
{
// If nothing is supposed to be showing
if (!this.active)
return;
// Determine which text to render (critical messages take precedence).
var msg = this.isShowingMessage ? this.currentMessageText : (this.menuActive ? this.currentMenuText : '');
if (typeof msg !== 'string')
msg = '';
// Append the typing display to the correct location in the message
if (this.typingActive) {
var newlines = (this.menuActive) ? 3 : 2;
newlines += this.maxLines - msg.split("\n").length;
msg += Ass.startSeq(true) + Ass.alpha("FF", true);
// Make sure we get aligned properly
for (var i = 0; i < newlines; i++) {
msg += "-\n";
}
// This spacing is annoyingly fiddly
if (!this.menuActive)
msg+="\n";
msg += Ass.alpha("00", true);
msg += "--------------\n";
msg += Ass.stopSeq(true);
msg += this.typingText;
}
// Tell mpv's OSD to show the text. It will automatically be replaced and
// refreshed every second while the menu remains open, to ensure that
// nothing else is able to overwrite our menu text.
// NOTE: The long display duration is important, because the JS engine lacks
// real threading, so any slow mpv API calls or slow JS functions will delay
// our redraw timer! Without a long display duration, the menu would vanish.
// NOTE: If a timer misses multiple intended ticks, it will only tick ONCE
// when catching up. So there can thankfully never be any large "backlog"!
mp.osd_message(msg, 1000);
};
SelectionMenu.prototype.renderMenu = function(selectionPrefix, renderMode)
{
var c = this.useTextColors,
finalString;
// Title.
finalString = Ass.startSeq(c)+Ass.alpha(this.menuFontAlpha, c)+
Ass.gray(c)+Ass.scale(75, c)+Ass.esc(this.title, c)+':'+
Ass.scale(100, c)+Ass.white(c)+'\n\n';
// Options.
if (this.options.length > 0) {
// Calculate start/end offsets around focal point.
var startIdx = this.selectionIdx - Math.floor(this.maxLines / 2);
if (startIdx < 0)
startIdx = 0;
var endIdx = startIdx + this.maxLines - 1,
maxIdx = this.options.length - 1;
if (endIdx > maxIdx)
endIdx = maxIdx;
// Increase number of leading lines if we've reached end of list.
var lineCount = (endIdx - startIdx) + 1, // "+1" to count start line too.
lineDiff = this.maxLines - lineCount;
startIdx -= lineDiff;
if (startIdx < 0)
startIdx = 0;
// Format and add all output lines.
var opt;
for (var i = startIdx; i <= endIdx; ++i) {
opt = this.options[i];
if (i === this.selectionIdx)
// NOTE: Prefix stays on screen until cursor-move or re-render.z
finalString += Ass.yellow(c)+'> '+(typeof selectionPrefix === 'string' ?
Ass.esc(selectionPrefix, c)+' ' : '');
// If the menu option has no children to move to
// then it should be colored red and ignored
if (opt.children != null && opt.children.length == 0)
finalString += Ass.color("FF0000", c);
finalString += (
i === startIdx && startIdx > 0 ? '...' :
(
i === endIdx && endIdx < maxIdx ? '...' : Ass.esc(
typeof opt === 'object' ? opt.name : opt,
c
)
)
);
// Display the now playing indicator
if (opt.isPlaying)
finalString += " <==";
if (i === this.selectionIdx || (opt.children != null && opt.children.length == 0))
finalString += Ass.white(c);
if (i !== endIdx)
finalString += '\n';
}
}
// End the Advanced SubStation command sequence.
finalString += Ass.stopSeq(c);
// Update cached menu text. But only open/redraw the menu if it's already
// active OR if we're NOT being prevented from going out of "hidden" state.
this.currentMenuText = finalString;
// Handle render mode:
// 1 = Only redraw if menu is onscreen (doesn't trigger open/redrawing if
// the menu is closed or busy showing a text message); 2 = Don't show/redraw
// at all (good for just updating the text cache silently); any other value
// (incl. undefined, aka default) = show/redraw the menu.
if ((renderMode === 1 && (!this.menuActive || this.isShowingMessage)) || renderMode === 2)
return;
this.showMenu();
};
SelectionMenu.prototype.show = function() {
var justOpened = false;
if (!this.isMenuActive()) {
justOpened = true;
this.originalFontSize = mp.get_property_number('osd-font-size');
mp.set_property('osd-font-size', this.menuFontSize);
// Redraw the currently active text every second and do periodic tasks.
// NOTE: This prevents other OSD scripts from removing our menu text.
var self = this;
if (this.menuInterval !== null)
clearInterval(this.menuInterval);
this.menuInterval = setInterval(function() {
self._renderActiveText();
self._handleAutoClose();
}, 1000);
// Get rid of any lingering "stop message" timeout and message.
this.stopMessage(true);
}
// Display the currently active text instantly.
this._renderActiveText();
if (justOpened) {
// Run "menu show" callback if registered.
if (typeof this.cbMenuShow === 'function') {
this._disableAutoCloseTimeout(); // Soft-disable while CB runs.
this.cbMenuShow('Menu-Show');
}
// Force an update/unlock of the activity timeout when menu opens.
this._updateAutoCloseTimeout(true); // Hard-update.
}
this.active = true;
}
SelectionMenu.prototype.hide = function() {
mp.osd_message('');
if (this.originalFontSize !== null)
mp.set_property('osd-font-size', this.originalFontSize);
if (this.menuInterval !== null) {
clearInterval(this.menuInterval);
this.menuInterval = null;
}
// Get rid of any lingering "stop message" timeout and message.
this.stopMessage(true);
// Run "menu hide" callback if registered.
if (typeof this.cbMenuHide === 'function')
this.cbMenuHide('Menu-Hide');
this.active = false;
}
SelectionMenu.prototype.showMenu = function()
{
if (!this.menuActive) {
this.menuActive = true;
this._registerMenuKeys();
}
if (!this.active)
this.show();
this._renderActiveText();
};
SelectionMenu.prototype.hideMenu = function()
{
if (this.menuActive) {
this.menuActive = false;
this._unregisterMenuKeys();
if (this.active && !this.typingActive)
this.hide();
else
this._renderActiveText();
}
};
SelectionMenu.prototype.showTyping = function()
{
this.typingActive = true;
if (!this.active)
this.show();
this._renderActiveText();
};
SelectionMenu.prototype.hideTyping = function()
{
this.typingActive = false;
if (this.active && !this.menuActive)
this.hide();
else
this._renderActiveText();
if (this.menuActive) {
this._registerMenuKeys();
}
};
SelectionMenu.prototype.showMessage = function(msg, durationMs, clearSelectionPrefix)
{
if (!this.isMenuActive())
return;
if (typeof msg !== 'string')
msg = 'showMessage: Invalid message value.';
if (typeof durationMs !== 'number')
durationMs = 800;
if (clearSelectionPrefix)
this.renderMenu(null, 2); // 2 = Only update text cache (no redraw).
this.isShowingMessage = true;
this.currentMessageText = msg;
this._renderActiveText();
this._disableAutoCloseTimeout(true); // Hard-disable (ignore msg idle time).
var self = this;
if (this.stopMessageTimeout !== null)
clearTimeout(this.stopMessageTimeout);
this.stopMessageTimeout = setTimeout(function() {
self.stopMessage();
}, durationMs);
};
SelectionMenu.prototype.stopMessage = function(preventRender)
{
if (this.stopMessageTimeout !== null) {
clearTimeout(this.stopMessageTimeout);
this.stopMessageTimeout = null;
}
this.isShowingMessage = false;
this.currentMessageText = '';
if (!preventRender)
this._renderActiveText();
this._updateAutoCloseTimeout(true); // Hard-update (last user activity).
};
module.exports = SelectionMenu;

View File

@ -0,0 +1,127 @@
import sys
import upnpclient
from lxml import etree
# Try to import wake on lan
wol = True
try:
import wakeonlan
except ImportError as error:
wol = False
import logging
# important information is passed through stdout so we need to supress
# the output of the upnp client module
logging.getLogger("upnpclient").setLevel(logging.CRITICAL)
logging.getLogger("ssdp").setLevel(logging.CRITICAL)
def wake(mac):
if wol:
try:
wakeonlan.send_magic_packet(mac);
print("packet sent")
except:
print("send failed")
else:
print("import failed")
def info(url, id, count):
device = upnpclient.Device(url)
result = device.ContentDirectory.Browse(ObjectID=id, BrowseFlag="BrowseMetadata", Filter="*", StartingIndex=0, RequestedCount=count, SortCriteria="")
root = etree.fromstring(result["Result"])
# Determine if we should be looking at items or containers
list = root.findall("./item", root.nsmap)
type = "item"
if len(list) == 0:
list = root.findall("./container", root.nsmap)
type = "container"
print(type)
for t in list:
print("")
print(t.findtext("upnp:episodeNumber", "No Episode Number", root.nsmap))
print(t.findtext("dc:description", "No Description", root.nsmap).encode().decode("ascii", errors='ignore'))
def browse(url, id, count):
device = upnpclient.Device(url)
result = device.ContentDirectory.Browse(ObjectID=id, BrowseFlag="BrowseDirectChildren", Filter="*", StartingIndex=0, RequestedCount=count, SortCriteria="")
root = etree.fromstring(result["Result"])
list = {}
# Determine if we should be looking at items or containers
list["item"] = root.findall("./item", root.nsmap)
list["container"] = root.findall("./container", root.nsmap)
for type in list.keys():
print(type + "s:")
for t in list[type]:
print("")
print(t.findtext("dc:title", "untitled", root.nsmap).encode().decode("ascii", errors='ignore'))
print(t.get("id"))
if type == "item":
print(t.findtext("res", "", root.nsmap))
print("----")
def list(timeout):
devices = []
possibleDevices = upnpclient.discover(timeout)
for device in possibleDevices:
if "MediaServer" in device.device_type:
addToList = True
for d in devices:
if d.friendly_name == device.friendly_name:
addToList = False
break
if addToList:
devices.append(device)
for device in devices:
print("")
print(device.friendly_name.encode().decode("ascii", errors='ignore'))
print(device.location)
def help():
print("mpvDLNA.py supports the following commands:")
print("-h, --help Prints the help dialog")
print("-v, --version Prints version information")
print("-l, --list Takes a timeout in seconds and outputs a list of DLNA Media Servers on the network")
print("-b, --browse Takes a DLNA url and the id of a DLNA element and outputs its direct children")
print("-i, --info Takes a DLNA url and the id of a DLNA element and outputs its metadata")
print("-w, --wake Takes a MAC address and attempts to send a wake on lan packet to it")
if len(sys.argv) == 2:
if sys.argv[1] == "-v" or sys.argv[1] == "--version":
print("mpvDLNA.py Plugin Version 2.0.0")
else:
help()
elif len(sys.argv) == 3:
if sys.argv[1] == "-l" or sys.argv[1] == "--list":
list(int(sys.argv[2]))
elif sys.argv[1] == "-w" or sys.argv[1] == "--wake":
wake(sys.argv[2])
else:
help()
elif len(sys.argv) >= 4:
count = 2000
if len(sys.argv) == 5:
count = sys.argv[4]
if sys.argv[1] == "-b" or sys.argv[1] == "--browse":
browse(sys.argv[2], sys.argv[3], count)
elif sys.argv[1] == "-i" or sys.argv[1] == "--info":
info(sys.argv[2], sys.argv[3], count)
else:
help()
else:
help()