# MMPC2 | # MMPC2 | ||||
The second version of Mort's Media PC. | |||||
## Goal | |||||
MMPC2 is meant to be running constantly on a HTPC, letting you stream media to | |||||
a TV and control the playback remotely, all through a web interface. It allows | |||||
you to stream three kinds of movies: | |||||
* URLs - YouTube, Vimeo, Dailymotion, plain HTTP streams, anything supported by | |||||
mpv and youtube-dl. | |||||
* Magnet Links - Stream any torrent. | |||||
* Files - Upload files and have them play. | |||||
For magnet links and files, subtitles will automatically be downloaded if you | |||||
want and subtitles exist for it in OpenSubtitles. | |||||
Modified version of mmpc2. | |||||
## Installation | ## Installation | ||||
With rolling release distros, the version of node in the package manager will | With rolling release distros, the version of node in the package manager will | ||||
generally be new enough - e.g `sudo pacman -S npm node` in arch is enough. | generally be new enough - e.g `sudo pacman -S npm node` in arch is enough. | ||||
### Getting a recent version of mpv | |||||
MMPC2 requires a relatively new version of mpv, newer than what's currently in | |||||
Debian and Ubuntu. For Ubuntu, you could just add this ppa to your system: | |||||
https://launchpad.net/~mc3man/+archive/Ubuntu/mpv-tests | |||||
sudo add-apt-repository ppa:mc3man/mpv-tests | |||||
sudo apt-get update | |||||
sudo apt-get install mpv | |||||
For Debian, you will probably have to either use the testing repositories to | |||||
install mpv, get some binary from somewhere, or compile it from source. I | |||||
compiled from source when installing it on the Debian stable (jessie), and the | |||||
instructions are a bit too long to include here, but you can get the source | |||||
from here: https://github.com/mpv-player/mpv/releases/latest and get the source | |||||
there. Make sure to compile with luajit support for youtube-dl, and libpulse | |||||
for pulseaudio (if you use that). | |||||
### Getting a recent version of youtbe-dl | |||||
Debian may ship a version of youtube-dl that's so old it doesn't really work | |||||
anymore - I had that problem with my Debian box. To fix that, uninstall | |||||
youtube-dl if you installed with apt-get (`apt-get remove youtube-dl`), and | |||||
install it through pip (installing pip if necessary) | |||||
sudo apt-get install python-pip | |||||
sudo pip install youtube-dl | |||||
## Configuration | ## Configuration | ||||
`conf.json` contains a couple of configuration options (assuming you copied | `conf.json` contains a couple of configuration options (assuming you copied | ||||
{ | { | ||||
"tmpdir": String. The directory to store temporary files in. | "tmpdir": String. The directory to store temporary files in. | ||||
Default: "tmp" | Default: "tmp" | ||||
"subtitles": String (or false). The language code for the subtitles, | |||||
or false for no subtitles. Default: "en" (english). | |||||
"additional_links": Array of objects which look like this: | |||||
{ "name": "some name", "url": "some URL" } | |||||
Lets you add more parts to the index page. I use it to link to an | |||||
instance of guacamole, a web based VNC client. | |||||
( https://guacamole.incubator.apache.org/ ) | |||||
} | } |
{ | { | ||||
"tmpdir": "tmp", | |||||
"subtitles": "en", | |||||
"additional_links": [] | |||||
"tmpdir": "tmp" | |||||
} | } |
var spawn = require("child_process").spawn; | |||||
module.exports = function(name, msg) { | |||||
var args = ["--urgency", "low"]; | |||||
args.push(name); | |||||
if (msg) args.push(msg); | |||||
spawn("notify-send", args); | |||||
} |
var pathlib = require("path"); | var pathlib = require("path"); | ||||
var urllib = require("url"); | var urllib = require("url"); | ||||
var fsutil = require("../fsutil"); | var fsutil = require("../fsutil"); | ||||
var notify = require("../notify"); | |||||
var player = require("./player"); | var player = require("./player"); | ||||
var httpStream = require("./http-stream"); | var httpStream = require("./http-stream"); | ||||
var torrentStreamer = require("./torrent-streamer"); | var torrentStreamer = require("./torrent-streamer"); | ||||
var subtitleFinder = require("./subtitle-finder"); | |||||
exports.httpPath = player.httpPath; | |||||
exports.httpPath = "/playback"; | |||||
exports.cleanupFiles = []; | exports.cleanupFiles = []; | ||||
player.init(app, conf); | player.init(app, conf); | ||||
httpStream.init(app, conf); | httpStream.init(app, conf); | ||||
torrentStreamer.init(app, conf); | torrentStreamer.init(app, conf); | ||||
subtitleFinder.init(app, conf); | |||||
} | |||||
/* | |||||
* Filename is optional; in case you want to provide a filename for subtitles | |||||
* but want a different path | |||||
*/ | |||||
exports.playFile = function(path, cb, filename) { | |||||
filename = filename || pathlib.basename(path); | |||||
notify("Playing file", filename); | |||||
// Find file's length | |||||
fs.stat(path, (err, stat) => { | |||||
if (err) { | |||||
console.trace(err); | |||||
return cb(); | |||||
} | |||||
// Find subtitles | |||||
subtitleFinder.find(stat.size, filename, subFile => { | |||||
// Play! | |||||
player.play(path, subFile, cb); | |||||
}); | |||||
}); | |||||
} | |||||
exports.playUrl = function(url, cb) { | |||||
notify("Playing url...", url); | |||||
// Just play, we won't bother finding subtitles | |||||
player.play(url, null, cb); | |||||
} | } | ||||
exports.playTorrent = function(magnet, cb) { | exports.playTorrent = function(magnet, cb) { | ||||
notify("Playing torrent..."); | |||||
// Stream torrent | // Stream torrent | ||||
torrentStreamer.stream(magnet, (err, filesize, filename) => { | torrentStreamer.stream(magnet, (err, filesize, filename) => { | ||||
if (err) | if (err) | ||||
return cb(err); | return cb(err); | ||||
// Find subtitles | |||||
subtitleFinder.find(filesize, filename, subFile => { | |||||
// Play! | |||||
notify("Starting playback.", filename); | |||||
player.play(app.getAddress()+httpStream.httpPath, subFile, cb); | |||||
}); | |||||
cb(); | |||||
}); | }); | ||||
} | } | ||||
return match[1]; | return match[1]; | ||||
} | } | ||||
notify("Finding magnet on torrent page...", url); | |||||
var urlobj = urllib.parse(url); | var urlobj = urllib.parse(url); | ||||
var o = urlobj.protocol === "https:" ? https : http; | var o = urlobj.protocol === "https:" ? https : http; | ||||
o.request(urlobj, res => { | o.request(urlobj, res => { | ||||
res | res | ||||
.on("data", d => str += d ) | .on("data", d => str += d ) | ||||
.on("error", err => { | .on("error", err => { | ||||
notify("Error downloading page!", err.toString()); | |||||
console.trace(err); | console.trace(err); | ||||
cb(); | cb(); | ||||
}) | }) | ||||
.on("end", () => { | .on("end", () => { | ||||
var magnet = findMagnet(str); | var magnet = findMagnet(str); | ||||
if (!magnet) { | if (!magnet) { | ||||
notify("No magnet link on page!"); | |||||
cb(); | |||||
cb("No magnet link on page!"); | |||||
return; | return; | ||||
} | } | ||||
var OpenSubtitles = require("opensubtitles-api"); | |||||
var fs = require("fs"); | |||||
var http = require("http"); | |||||
var urllib = require("url"); | |||||
var subs = new OpenSubtitles({ useragent: "mmpc-media-streamer" }); | |||||
var conf; | |||||
exports.init = function(app, _conf) { | |||||
conf = _conf; | |||||
} | |||||
exports.find = function(filesize, filename, cb) { | |||||
if (!conf.subtitles) | |||||
return cb(); | |||||
var subFile = conf.tmpdir+"/subs.srt"; | |||||
try { | |||||
fs.unlinkSync(subFile); | |||||
} catch (err) {} | |||||
subs.search({ | |||||
sublanguageid: conf.subtitles, | |||||
filesize: filesize, | |||||
filename: filename | |||||
}).then(subtitles => { | |||||
var sub = subtitles[conf.subtitles]; | |||||
if (!sub || !sub.url) | |||||
return cb(); | |||||
try { | |||||
var ws = fs.createWriteStream(subFile); | |||||
} catch (err) { | |||||
console.trace(err); | |||||
cb(); | |||||
} | |||||
http.request(urllib.parse(sub.url), res => { | |||||
res.pipe(ws); | |||||
res | |||||
.on("error", err => { | |||||
notify("Error finding subtitles", err.toString()); | |||||
console.trace(err); | |||||
cb(); | |||||
}) | |||||
.on("end", () => { | |||||
ws.close(); | |||||
setTimeout(() => cb(subFile), 500); | |||||
}); | |||||
}).end(); | |||||
}); | |||||
} |
var fs = require("fs"); | var fs = require("fs"); | ||||
var pathlib = require("path"); | var pathlib = require("path"); | ||||
var web = require("webstuff"); | var web = require("webstuff"); | ||||
var notify = require("./js/notify"); | |||||
var play = require("./js/play"); | var play = require("./js/play"); | ||||
var fsutil = require("./js/fsutil"); | var fsutil = require("./js/fsutil"); | ||||
if (!fields.url) | if (!fields.url) | ||||
return res.redirect("/"); | return res.redirect("/"); | ||||
function cb() { | |||||
res.redirect(play.httpPath); | |||||
function cb(err) { | |||||
if (err) { | |||||
console.log(err); | |||||
res.redirect("/"); | |||||
} else { | |||||
res.redirect(play.httpPath); | |||||
} | |||||
} | } | ||||
if (fields.url.indexOf("magnet:") === 0) { | if (fields.url.indexOf("magnet:") === 0) { | ||||
} else if (fields.url.indexOf("/torrent") !== -1) { | } else if (fields.url.indexOf("/torrent") !== -1) { | ||||
play.playTorrentPage(fields.url, cb); | play.playTorrentPage(fields.url, cb); | ||||
} else { | } else { | ||||
play.playUrl(fields.url, cb); | |||||
} | |||||
}); | |||||
}); | |||||
app.post("/play/file", (req, res) => { | |||||
notify("Receiving file..."); | |||||
req.parseBody((err, fields, files) => { | |||||
var file = files.file; | |||||
if (file == null || !file.name || file.size === 0) | |||||
return res.redirect("/"); | return res.redirect("/"); | ||||
var fname = conf.tmpdir+"/uploaded-file"+pathlib.extname(file.name); | |||||
fsutil.move(file.path, fname, err => { | |||||
if (err) { | |||||
console.trace(err); | |||||
return res.redirect("/"); | |||||
} | |||||
play.cleanupFiles.push(fname); | |||||
play.playFile(fname, () => { | |||||
res.redirect(play.httpPath); | |||||
}, file.name); | |||||
}); | |||||
} | |||||
}); | }); | ||||
}); | }); | ||||
<input type="url" name="url" autocomplete="off"> | <input type="url" name="url" autocomplete="off"> | ||||
<button>Play</button> | <button>Play</button> | ||||
</form> | </form> | ||||
<form class="part" method="post" enctype="multipart/form-data" action="/play/file"> | |||||
<div class="name">File:</div> | |||||
<input type="file" name="file" autocomplete="off"> | |||||
<button>Play</button> | |||||
</form> | |||||
</div> | </div> | ||||
<script src="/webstuff.js"></script> | <script src="/webstuff.js"></script> |
<link rel="stylesheet" href="style.css"> | <link rel="stylesheet" href="style.css"> | ||||
</head> | </head> | ||||
<body> | <body> | ||||
<div id="group-info"> | |||||
<div id="is-playing">Not Playing</div> | |||||
<div id="progress-text"></div> | |||||
</div> | |||||
<div id="group-bar"> | |||||
<progress id="progress"></progress> | |||||
</div> | |||||
<div id="group-buttons"> | |||||
<button id="mute">Mute</button> | |||||
<button id="skip-back"><</button> | |||||
<button id="pause">||</button> | |||||
<button id="skip-forward">></button> | |||||
<button id="exit">Exit</button> | |||||
</div> | |||||
<div id="group-volume"> | |||||
Volume <span id="volume-text">0</span>%<br> | |||||
<input id="volume" type="range"> | |||||
</div> | |||||
<div id="group-sub-delay"> | |||||
Subtitle Delay <span id="sub-delay">0</span>s<br> | |||||
<button id="sub-delay-less2">-1</button> | |||||
<button id="sub-delay-less">-0.1</button> | |||||
<button id="sub-delay-reset">0</button> | |||||
<button id="sub-delay-more">+0.1</button> | |||||
<button id="sub-delay-more2">+1</button> | |||||
</div> | |||||
<script src="/webstuff.js"></script> | |||||
<script src="script.js"></script> | |||||
<video controls autoplay> | |||||
<source src="/http-stream"> | |||||
Your browser does not support the video tag. | |||||
</video> | |||||
</body> | </body> | ||||
</html> | </html> |
function timeformat(sec) { | |||||
var d = new Date(null); | |||||
d.setSeconds(Math.floor(sec)) | |||||
return d.toISOString().substr(11, 8); | |||||
} | |||||
var elems = { | |||||
is_playing: $$("#is-playing"), | |||||
progress_text: $$("#progress-text"), | |||||
progress: $$("#progress"), | |||||
pause: $$("#pause"), | |||||
skip_back: $$("#skip-back"), | |||||
skip_forward: $$("#skip-forward"), | |||||
mute: $$("#mute"), | |||||
exit: $$("#exit"), | |||||
volume: $$("#volume"), | |||||
volume_text: $$("#volume-text"), | |||||
sub_delay: $$("#sub-delay"), | |||||
sub_delay_less: $$("#sub-delay-less"), | |||||
sub_delay_less2: $$("#sub-delay-less2"), | |||||
sub_delay_more: $$("#sub-delay-more"), | |||||
sub_delay_more2: $$("#sub-delay-more2"), | |||||
sub_delay_reset: $$("#sub-delay-reset") | |||||
}; | |||||
var state = {}; | |||||
/* | |||||
* Update GUI stuff | |||||
*/ | |||||
function update(state) { | |||||
// Playing | |||||
if (state.playing) | |||||
elems.is_playing.innerHTML = "Playing"; | |||||
else | |||||
location.href = "/"; | |||||
// Progress text | |||||
elems.progress_text.innerHTML = | |||||
timeformat(state.time_pos)+"/"+ | |||||
timeformat(state.duration); | |||||
// Progress bar | |||||
elems.progress.value = state.time_pos; | |||||
elems.progress.max = state.duration; | |||||
// Buttons | |||||
elems.pause.className = state.paused ? "active" : ""; | |||||
elems.mute.className = state.muted ? "active" : ""; | |||||
// Volume | |||||
elems.volume.min = 0; | |||||
elems.volume.max = state.volume_max; | |||||
elems.volume.value = state.volume; | |||||
elems.volume.step = 5; | |||||
elems.volume_text.innerHTML = state.volume; | |||||
// Sub Delay | |||||
elems.sub_delay.innerHTML = state.sub_delay; | |||||
} | |||||
function checkState() { | |||||
post("/playback/state", null, function(err, res) { | |||||
if (err) | |||||
return; | |||||
state = JSON.parse(res); | |||||
update(state); | |||||
}); | |||||
} | |||||
checkState(); | |||||
setInterval(checkState, 500); | |||||
/* | |||||
* React to input | |||||
*/ | |||||
function playerset(key, val) { | |||||
post("/playback/set/"+key+"/"+val, null, function() { | |||||
checkState(); | |||||
}); | |||||
} | |||||
// Set time | |||||
elems.progress.addEventListener("click", function(evt) { | |||||
var pos = (evt.clientX - evt.target.offsetLeft) / evt.target.clientWidth; | |||||
pos *= evt.target.max; | |||||
playerset("time-pos", pos); | |||||
}); | |||||
// Toggle pause | |||||
elems.pause.addEventListener("click", function() { | |||||
if (state.paused) { | |||||
playerset("pause", "no"); | |||||
} else { | |||||
playerset("pause", "yes"); | |||||
playerset("time-pos", state.time_pos - 1); | |||||
} | |||||
}); | |||||
// Back 15 seconds | |||||
elems.skip_back.addEventListener("click", function() { | |||||
playerset("time-pos", state.time_pos - 15); | |||||
}); | |||||
// Forwards 15 seconds | |||||
elems.skip_forward.addEventListener("click", function() { | |||||
playerset("time-pos", state.time_pos + 15); | |||||
}); | |||||
// Toggle mute | |||||
elems.mute.addEventListener("click", function(evt) { | |||||
if (state.muted) | |||||
playerset("mute", "no"); | |||||
else | |||||
playerset("mute", "yes"); | |||||
}); | |||||
// Exit | |||||
elems.exit.addEventListener("click", function(evt) { | |||||
post("/playback/exit", null, function() {}); | |||||
}); | |||||
// Set volume | |||||
elems.volume.addEventListener("change", function(evt) { | |||||
playerset("volume", evt.target.value); | |||||
}); | |||||
elems.volume.addEventListener("keydown", function(evt) { | |||||
if (evt.keyCode === 37 || evt.keyCode === 40) | |||||
playerset("volume", parseInt(evt.target.value) - parseInt(evt.target.step)); | |||||
else if (evt.keyCode === 38 || evt.keyCode === 39) | |||||
playerset("volume", parseInt(evt.target.value) + parseInt(evt.target.step)); | |||||
}); | |||||
// Less subtitle delay | |||||
elems.sub_delay_less.addEventListener("click", function() { | |||||
playerset("sub-delay", state.sub_delay - 0.1); | |||||
}); | |||||
elems.sub_delay_less2.addEventListener("click", function() { | |||||
playerset("sub-delay", state.sub_delay - 1); | |||||
}); | |||||
// More subtitle delay | |||||
elems.sub_delay_more.addEventListener("click", function() { | |||||
playerset("sub-delay", state.sub_delay + 0.1); | |||||
}); | |||||
elems.sub_delay_more2.addEventListener("click", function() { | |||||
playerset("sub-delay", state.sub_delay + 1); | |||||
}); | |||||
// Set subtitle delay to 0 | |||||
elems.sub_delay_reset.addEventListener("click", function() { | |||||
playerset("sub-delay", 0); | |||||
}); | |||||
window.addEventListener("keydown", function(evt) { | |||||
console.log(evt.keyCode); | |||||
}); |
* { | |||||
box-sizing: border-box; | |||||
line-height: 30px; | |||||
font-family: Sans-Serif | |||||
} | |||||
body { | body { | ||||
margin: 12px; | |||||
} | |||||
body > div { | |||||
margin-bottom: 12px; | |||||
} | |||||
button { | |||||
display: inline-block; | |||||
padding: 0px; | |||||
border: 1px solid #666; | |||||
width: 50px; | |||||
height: 32px; | |||||
background: #ddd; | |||||
margin-left: 2px; | |||||
margin-right: 2px; | |||||
} | |||||
button:active, | |||||
button.active { | |||||
background: #aaa; | |||||
} | |||||
/* | |||||
* Playback | |||||
*/ | |||||
#is-playing, | |||||
#progress-text { | |||||
display: inline-block; | |||||
} | |||||
#progress-text { | |||||
float: right; | |||||
} | |||||
#group-buttons { | |||||
text-align: center; | |||||
} | |||||
progress { | |||||
width: 100%; | |||||
height: 24px; | |||||
} | |||||
/* | |||||
* Volume | |||||
*/ | |||||
input[type="range"] { | |||||
border: 1px solid rgba(0, 0, 0, 0); | |||||
box-sizing: border-box; | |||||
margin: 0px; | margin: 0px; | ||||
width: 100%; | |||||
} | |||||
#group-volume { | |||||
text-align: center; | |||||
border-top: 1px solid #ccc; | |||||
} | |||||
#group-volume { | |||||
padding-top: 12px; | |||||
margin-top: 24px; | |||||
} | } | ||||
/* | |||||
* Sub Delay | |||||
*/ | |||||
#group-sub-delay { | |||||
text-align: center; | |||||
} | |||||
#group-sub-delay { | |||||
border-top: 1px solid #ccc; | |||||
} | |||||
#group-sub-delay { | |||||
padding-top: 12px; | |||||
} | |||||
#group-sub-delay button { | |||||
margin-top: 6px; | |||||
video { | |||||
width: 100%; | |||||
} | } |