node_modules | node_modules | ||||
conf.json | conf.json | ||||
tmp |
# 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. | |||||
## Installation | |||||
Install git, a recent version of node, and npm, clone the repository, run `npm | |||||
install`, copy `conf.json.example` to `conf.json`, and run `node server.js`. | |||||
git clone https://github.com/mortie/mmpc2.git | |||||
cd mmpc2 | |||||
npm install | |||||
cp conf.json.example | |||||
node server.js | |||||
### Getting a recent version of node.js | |||||
Many distros ship old versions of node, which won't work with MMPC2. To fix | |||||
this, install node.js and npm (`sudo apt-get install nodejs-legacy npm` on | |||||
Debian and Ubuntu), then install `n` and install a new version of node with | |||||
that. | |||||
sudo apt-get install nodejs-legacy npm | |||||
sudo npm install -g n | |||||
sudo n stable | |||||
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. | |||||
### 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 | |||||
`conf.json` contains a couple of configuration options (assuming you copied | |||||
`conf.json.example` to `conf.json`). These are: | |||||
{ | |||||
"tmpdir": String. The directory to store temporary files in. | |||||
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", | "tmpdir": "tmp", | ||||
"subtitles": "en" | |||||
"subtitles": "en", | |||||
"additional_links": [] | |||||
} | } |
var fs = require("fs"); | |||||
var pathlib = require("path"); | |||||
/* | |||||
* Move a file by copying it, to let it move across devices | |||||
*/ | |||||
exports.move = function(src, dst, cb) { | |||||
var ws; | |||||
try { | |||||
ws = fs.createWriteStream(dst); | |||||
} catch (err) { return cb(err) } | |||||
var rs; | |||||
try { | |||||
rs = fs.createReadStream(src); | |||||
} catch (err) { return cb(err) } | |||||
rs | |||||
.on("data", d => ws.write(d)) | |||||
.on("end", () => { | |||||
ws.end(); | |||||
cb(); | |||||
}) | |||||
.on("error", cb); | |||||
} | |||||
/* | |||||
* Remove directory, deleting its content in the process | |||||
*/ | |||||
exports.rmdir = function(dir) { | |||||
console.log("rmdir", dir); | |||||
try { | |||||
fs.accessSync(dir, fs.F_OK) | |||||
} catch (err) { | |||||
console.trace(err); | |||||
return; | |||||
} | |||||
fs.readdirSync(dir).forEach(f => { | |||||
var fname = pathlib.join(dir, f); | |||||
var stat = fs.statSync(fname); | |||||
if (stat.isDirectory()) | |||||
exports.rmdir(fname); | |||||
else | |||||
fs.unlinkSync(fname) | |||||
}); | |||||
fs.rmdir(dir); | |||||
} |
var fs = require("fs"); | var fs = require("fs"); | ||||
var pathlib = require("path"); | var pathlib = require("path"); | ||||
var fsutil = require("../fsutil"); | |||||
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"); | ||||
exports.httpPath = player.httpPath; | exports.httpPath = player.httpPath; | ||||
exports.cleanupFiles = []; | |||||
var app; | var app; | ||||
var conf | |||||
exports.init = function(_app, conf) { | |||||
exports.init = function(_app, _conf) { | |||||
app = _app; | app = _app; | ||||
conf = _conf; | |||||
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); | subtitleFinder.init(app, conf); | ||||
} | } | ||||
exports.playFile = function(path, cb) { | |||||
/* | |||||
* 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); | |||||
// Find file's length | // Find file's length | ||||
fs.stat(path, (err, stat) => { | fs.stat(path, (err, stat) => { | ||||
if (err) { | |||||
console.trace(err); | |||||
return cb(); | |||||
} | |||||
// Find subtitles | // Find subtitles | ||||
subtitleFinder.find(stat.size, pathlib.basename(path), subFile => { | |||||
subtitleFinder.find(stat.size, filename, subFile => { | |||||
exports.cleanupFiles.push(subFile); | |||||
// Play! | // Play! | ||||
player.play(path, subFile, cb); | player.play(path, subFile, cb); | ||||
// Find subtitles | // Find subtitles | ||||
subtitleFinder.find(filesize, filename, subFile => { | subtitleFinder.find(filesize, filename, subFile => { | ||||
exports.cleanupFiles.push(subFile); | |||||
// Play! | // Play! | ||||
player.play(app.getAddress()+httpStream.httpPath, subFile, cb); | player.play(app.getAddress()+httpStream.httpPath, subFile, cb); | ||||
player.onstop = function() { | player.onstop = function() { | ||||
torrentStreamer.stop(); | torrentStreamer.stop(); | ||||
httpStream.stop(); | httpStream.stop(); | ||||
exports.cleanupFiles.forEach(f => { | |||||
fs.unlink(f, err => { if (err) console.trace(err) }); | |||||
}); | |||||
exports.cleanupFiles = []; | |||||
fsutil.rmdir(conf.tmpdir+"/torrent-stream"); | |||||
} | } |
path, | path, | ||||
"--no-cache-pause", | "--no-cache-pause", | ||||
"--no-resume-playback", | "--no-resume-playback", | ||||
"--input-unix-socket", ipcServer | |||||
"--input-ipc-server", ipcServer | |||||
]; | ]; | ||||
if (subFile) { | if (subFile) { |
var fs = require("fs"); | var fs = require("fs"); | ||||
var pathlib = require("path"); | |||||
var web = require("webstuff"); | var web = require("webstuff"); | ||||
var play = require("./js/play"); | var play = require("./js/play"); | ||||
var fsutil = require("./js/fsutil"); | |||||
var conf = JSON.parse(fs.readFileSync("conf.json")); | var conf = JSON.parse(fs.readFileSync("conf.json")); | ||||
}); | }); | ||||
}); | }); | ||||
}); | }); | ||||
app.post("/play/file", (req, res) => { | |||||
req.parseBody((err, fields, files) => { | |||||
var file = files.file; | |||||
if (file == null || !file.name || file.size === 0) | |||||
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); | |||||
}); | |||||
}); | |||||
}); | |||||
app.get("/additional-links", (req, res) => { | |||||
res.json(conf.additional_links); | |||||
}); |
<input type="url" name="magnet" autocomplete="off"> | <input type="url" name="magnet" 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> | ||||
<script src="/script.js"></script> | |||||
</body> | </body> | ||||
</html> | </html> |
get("/additional-links", function(err, res) { | |||||
if (err) | |||||
return console.trace(err); | |||||
JSON.parse(res).forEach(function(link) { | |||||
var form = document.createElement("form"); | |||||
form.className = "part"; | |||||
form.method = "get"; | |||||
form.action = link.url; | |||||
var btn = document.createElement("button"); | |||||
btn.className = "link"; | |||||
btn.innerHTML = link.name; | |||||
form.appendChild(btn); | |||||
$$("#parts").appendChild(form); | |||||
}); | |||||
}); |
#parts .part input { | #parts .part input { | ||||
width: calc(100% - 80px); | width: calc(100% - 80px); | ||||
} | } | ||||
#parts .part input[type=url], | |||||
#parts .part input[type=text] { | |||||
padding-left: 6px; | |||||
} | |||||
#parts .part button { | #parts .part button { | ||||
width: 75px; | width: 75px; | ||||
float: right; | float: right; | ||||
} | } | ||||
#parts .part button.link { | |||||
width: 100%; | |||||
height: 42px; | |||||
float: none; | |||||
} | |||||
#parts .part input, | #parts .part input, | ||||
#parts .part button { | #parts .part button { | ||||
height: 28px; | |||||
height: 34px; | |||||
line-height: 0px; | |||||
} | } |