#18 Allowing to play audio files in channels. They actually play now, but requires editing the channel json manually because there is no UI to import them yet.
This commit is contained in:
parent
f32a6d1397
commit
9f194e62c6
4
index.js
4
index.js
@ -212,6 +212,10 @@ function initDB(db, channelDB) {
|
||||
let data = fs.readFileSync(path.resolve(path.join(__dirname, 'resources/generic-offline-screen.png')))
|
||||
fs.writeFileSync(process.env.DATABASE + '/images/generic-offline-screen.png', data)
|
||||
}
|
||||
if (!fs.existsSync(process.env.DATABASE + '/images/generic-music-screen.png')) {
|
||||
let data = fs.readFileSync(path.resolve(path.join(__dirname, 'resources/generic-music-screen.png')))
|
||||
fs.writeFileSync(process.env.DATABASE + '/images/generic-music-screen.png', data)
|
||||
}
|
||||
if (!fs.existsSync(process.env.DATABASE + '/images/loading-screen.png')) {
|
||||
let data = fs.readFileSync(path.resolve(path.join(__dirname, 'resources/loading-screen.png')))
|
||||
fs.writeFileSync(process.env.DATABASE + '/images/loading-screen.png', data)
|
||||
|
||||
BIN
resources/generic-music-screen.png
Normal file
BIN
resources/generic-music-screen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@ -111,6 +111,7 @@ class FFMPEG extends events.EventEmitter {
|
||||
let ffmpegArgs = [
|
||||
`-threads`, isConcatPlaylist? 1 : this.opts.threads,
|
||||
`-fflags`, `+genpts+discardcorrupt+igndts`];
|
||||
let stillImage = false;
|
||||
|
||||
if (
|
||||
(limitRead === true)
|
||||
@ -185,28 +186,57 @@ class FFMPEG extends events.EventEmitter {
|
||||
}
|
||||
|
||||
// prepare input streams
|
||||
if ( typeof(streamUrl.errorTitle) !== 'undefined') {
|
||||
if ( ( typeof(streamUrl.errorTitle) !== 'undefined') || (streamStats.audioOnly) ) {
|
||||
doOverlay = false; //never show icon in the error screen
|
||||
// for error stream, we have to generate the input as well
|
||||
this.apad = false; //all of these generate audio correctly-aligned to video so there is no need for apad
|
||||
this.audioChannelsSampleRate = true; //we'll need these
|
||||
|
||||
if (this.ensureResolution) {
|
||||
//all of the error strings already choose the resolution to
|
||||
//match iW x iH , so with this we save ourselves a second
|
||||
// scale filter
|
||||
iW = this.wantedW;
|
||||
iH = this.wantedH;
|
||||
}
|
||||
|
||||
if (this.audioOnly !== true) {
|
||||
ffmpegArgs.push("-r" , "24");
|
||||
if ( streamUrl.errorTitle == 'offline' ) {
|
||||
let pic = null;
|
||||
|
||||
//does an image to play exist?
|
||||
if (
|
||||
(typeof(streamUrl.errorTitle) === 'undefined')
|
||||
&&
|
||||
(streamStats.audioOnly)
|
||||
) {
|
||||
pic = streamStats.placeholderImage;
|
||||
} else if ( streamUrl.errorTitle == 'offline') {
|
||||
pic = `${this.channel.offlinePicture}`;
|
||||
} else if ( this.opts.errorScreen == 'pic' ) {
|
||||
pic = `${this.errorPicturePath}`;
|
||||
}
|
||||
|
||||
if (pic != null) {
|
||||
ffmpegArgs.push(
|
||||
'-loop', '1',
|
||||
'-i', `${this.channel.offlinePicture}`,
|
||||
'-i', pic,
|
||||
);
|
||||
videoComplex = `;[${inputFiles++}:0]loop=loop=-1:size=1:start=0[looped];[looped]scale=${iW}:${iH}[videoy];[videoy]realtime[videox]`;
|
||||
if (
|
||||
(typeof duration === 'undefined')
|
||||
&&
|
||||
(typeof(streamStats.duration) !== 'undefined' )
|
||||
) {
|
||||
//add 150 milliseconds just in case, exact duration seems to cut out the last bits of music some times.
|
||||
duration = `${streamStats.duration + 150}ms`;
|
||||
}
|
||||
videoComplex = `;[${inputFiles++}:0]loop=loop=-1:size=1:start=0[looped]`;
|
||||
videoComplex += `;[looped]format=yuv420p[formatted]`;
|
||||
let stream = "scaled";
|
||||
videoComplex +=`;[formatted]scale=w=${iW}:h=${iH}:force_original_aspect_ratio=1[scaled]`;
|
||||
if (this.ensureResolution) {
|
||||
stream = "padded";
|
||||
videoComplex += `;[scaled]pad=${iW}:${iH}:(ow-iw)/2:(oh-ih)/2[padded]`;
|
||||
}
|
||||
videoComplex +=`;[${stream}]realtime[videox]`;
|
||||
stillImage = true;
|
||||
} else if (this.opts.errorScreen == 'static') {
|
||||
ffmpegArgs.push(
|
||||
'-f', 'lavfi',
|
||||
@ -232,23 +262,17 @@ class FFMPEG extends events.EventEmitter {
|
||||
inputFiles++;
|
||||
|
||||
videoComplex = `;drawtext=fontfile=${process.env.DATABASE}/font.ttf:fontsize=${sz1}:fontcolor=white:x=(w-text_w)/2:y=(h-text_h)/2:text='${streamUrl.errorTitle}',drawtext=fontfile=${process.env.DATABASE}/font.ttf:fontsize=${sz2}:fontcolor=white:x=(w-text_w)/2:y=(h+text_h+${sz3})/2:text='${streamUrl.subtitle}'[videoy];[videoy]realtime[videox]`;
|
||||
} else if (this.opts.errorScreen == 'blank') {
|
||||
} else { //blank
|
||||
ffmpegArgs.push(
|
||||
'-f', 'lavfi',
|
||||
'-i', `color=c=black:s=${iW}x${iH}`
|
||||
);
|
||||
inputFiles++;
|
||||
videoComplex = `;realtime[videox]`;
|
||||
} else {//'pic'
|
||||
ffmpegArgs.push(
|
||||
'-loop', '1',
|
||||
'-i', `${this.errorPicturePath}`,
|
||||
);
|
||||
inputFiles++;
|
||||
videoComplex = `;[${videoFile+1}:0]scale=${iW}:${iH}[videoy];[videoy]realtime[videox]`;
|
||||
}
|
||||
}
|
||||
let durstr = `duration=${streamStats.duration}ms`;
|
||||
if (typeof(streamUrl.errorTitle) !== 'undefined') {
|
||||
//silent
|
||||
audioComplex = `;aevalsrc=0:${durstr}[audioy]`;
|
||||
if ( streamUrl.errorTitle == 'offline' ) {
|
||||
@ -280,8 +304,9 @@ class FFMPEG extends events.EventEmitter {
|
||||
ffmpegArgs.push('-pix_fmt' , 'yuv420p' );
|
||||
}
|
||||
audioComplex += ';[audioy]arealtime[audiox]';
|
||||
currentVideo = "[videox]";
|
||||
currentAudio = "[audiox]";
|
||||
}
|
||||
currentVideo = "[videox]";
|
||||
}
|
||||
if (doOverlay) {
|
||||
if (watermark.animated === true) {
|
||||
@ -297,9 +322,13 @@ class FFMPEG extends events.EventEmitter {
|
||||
let algo = this.opts.scalingAlgorithm;
|
||||
let resizeMsg = "";
|
||||
if (
|
||||
(!streamStats.audioOnly)
|
||||
&&
|
||||
(
|
||||
(this.ensureResolution && ( streamStats.anamorphic || (iW != this.wantedW || iH != this.wantedH) ) )
|
||||
||
|
||||
isLargerResolution(iW, iH, this.wantedW, this.wantedH)
|
||||
)
|
||||
) {
|
||||
//scaler stuff, need to change the size of the video and also add bars
|
||||
// calculate wanted aspect ratio
|
||||
@ -444,6 +473,9 @@ class FFMPEG extends events.EventEmitter {
|
||||
`-c:v`, (transcodeVideo ? this.opts.videoEncoder : 'copy'),
|
||||
`-sc_threshold`, `1000000000`,
|
||||
);
|
||||
if (stillImage) {
|
||||
ffmpegArgs.push('-tune', 'stillimage');
|
||||
}
|
||||
}
|
||||
ffmpegArgs.push(
|
||||
'-map', currentAudio,
|
||||
@ -506,14 +538,14 @@ class FFMPEG extends events.EventEmitter {
|
||||
`service_provider="dizqueTV"`,
|
||||
`-metadata`,
|
||||
`service_name="${this.channel.name}"`,
|
||||
`-f`, `mpegts`);
|
||||
);
|
||||
|
||||
//t should be before output
|
||||
//t should be before -f
|
||||
if (typeof duration !== 'undefined') {
|
||||
ffmpegArgs.push(`-t`, duration)
|
||||
ffmpegArgs.push(`-t`, `${duration}`);
|
||||
}
|
||||
|
||||
ffmpegArgs.push(`pipe:1`)
|
||||
ffmpegArgs.push(`-f`, `mpegts`, `pipe:1`)
|
||||
|
||||
let doLogs = this.opts.logFfmpeg && !isConcatPlaylist;
|
||||
if (this.hasBeenKilled) {
|
||||
@ -521,6 +553,7 @@ class FFMPEG extends events.EventEmitter {
|
||||
}
|
||||
this.ffmpeg = spawn(this.ffmpegPath, ffmpegArgs, { stdio: ['ignore', 'pipe', (doLogs?process.stderr:"ignore") ] } );
|
||||
if (this.hasBeenKilled) {
|
||||
console.log("Send SIGKILL to ffmpeg");
|
||||
this.ffmpeg.kill("SIGKILL");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -124,13 +124,9 @@ class PlexPlayer {
|
||||
return emitter;
|
||||
|
||||
} catch(err) {
|
||||
if (err instanceof Error) {
|
||||
throw err;
|
||||
} else {
|
||||
return Error("Error when playing plex program: " + JSON.stringify(err) );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PlexPlayer;
|
||||
|
||||
@ -35,6 +35,10 @@ class PlexTranscoder {
|
||||
this.updateInterval = 30000
|
||||
this.updatingPlex = undefined
|
||||
this.playState = "stopped"
|
||||
this.albumArt = {
|
||||
attempted : false,
|
||||
path: null,
|
||||
}
|
||||
}
|
||||
|
||||
async getStream(deinterlace) {
|
||||
@ -53,7 +57,7 @@ class PlexTranscoder {
|
||||
} else {
|
||||
try {
|
||||
this.log("Setting transcoding parameters")
|
||||
this.setTranscodingArgs(stream.directPlay, true, deinterlace)
|
||||
this.setTranscodingArgs(stream.directPlay, true, deinterlace, true)
|
||||
await this.getDecision(stream.directPlay);
|
||||
if (this.isDirectPlay()) {
|
||||
stream.directPlay = true;
|
||||
@ -110,13 +114,14 @@ class PlexTranscoder {
|
||||
return stream
|
||||
}
|
||||
|
||||
setTranscodingArgs(directPlay, directStream, deinterlace) {
|
||||
setTranscodingArgs(directPlay, directStream, deinterlace, firstTry) {
|
||||
let resolution = (directStream) ? this.settings.maxPlayableResolution : this.settings.maxTranscodeResolution
|
||||
let bitrate = (directStream) ? this.settings.directStreamBitrate : this.settings.transcodeBitrate
|
||||
let mediaBufferSize = (directStream) ? this.settings.mediaBufferSize : this.settings.transcodeMediaBufferSize
|
||||
let subtitles = (this.settings.enableSubtitles) ? "burn" : "none" // subtitle options: burn, none, embedded, sidecar
|
||||
let streamContainer = "mpegts" // Other option is mkv, mkv has the option of copying it's subs for later processing
|
||||
let isDirectPlay = (directPlay) ? '1' : '0'
|
||||
let isDirectPlay = (directPlay) ? '1' : (firstTry? '': '0');
|
||||
let hasMDE = '1';
|
||||
|
||||
let videoQuality=`100` // Not sure how this applies, maybe this works if maxVideoBitrate is not set
|
||||
let profileName=`Generic` // Blank profile, everything is specified through X-Plex-Client-Profile-Extra
|
||||
@ -166,7 +171,7 @@ X-Plex-Token=${this.server.accessToken}&\
|
||||
X-Plex-Client-Profile-Extra=${clientProfile_enc}&\
|
||||
protocol=${this.settings.streamProtocol}&\
|
||||
Connection=keep-alive&\
|
||||
hasMDE=1&\
|
||||
hasMDE=${hasMDE}&\
|
||||
path=${this.key}&\
|
||||
mediaIndex=0&\
|
||||
partIndex=0&\
|
||||
@ -206,6 +211,9 @@ lang=en`
|
||||
|
||||
isDirectPlay() {
|
||||
try {
|
||||
if (this.getVideoStats().audioOnly) {
|
||||
return this.getVideoStats().audioDecision === "copy";
|
||||
}
|
||||
return this.getVideoStats().videoDecision === "copy" && this.getVideoStats().audioDecision === "copy";
|
||||
} catch (e) {
|
||||
console.log("Error at decision:" , e);
|
||||
@ -217,7 +225,6 @@ lang=en`
|
||||
let ret = {}
|
||||
try {
|
||||
let streams = this.decisionJson.MediaContainer.Metadata[0].Media[0].Part[0].Stream
|
||||
|
||||
ret.duration = parseFloat( this.decisionJson.MediaContainer.Metadata[0].Media[0].Part[0].duration );
|
||||
streams.forEach(function (_stream, $index) {
|
||||
// Video
|
||||
@ -257,6 +264,14 @@ lang=en`
|
||||
} catch (e) {
|
||||
console.log("Error at decision:" , e);
|
||||
}
|
||||
if (typeof(ret.videoCodec) === 'undefined') {
|
||||
ret.audioOnly = true;
|
||||
ret.placeholderImage = (this.albumArt.path != null) ?
|
||||
ret.placeholderImage = this.albumArt.path
|
||||
:
|
||||
ret.placeholderImage = `http://localhost:${process.env.PORT}/images/generic-music-screen.png`
|
||||
;
|
||||
}
|
||||
|
||||
this.log("Current video stats:")
|
||||
this.log(ret)
|
||||
@ -300,23 +315,56 @@ lang=en`
|
||||
}
|
||||
|
||||
async getDecisionUnmanaged(directPlay) {
|
||||
let res = await axios.get(`${this.server.uri}/video/:/transcode/universal/decision?${this.transcodingArgs}`, {
|
||||
let url = `${this.server.uri}/video/:/transcode/universal/decision?${this.transcodingArgs}`;
|
||||
let res = await axios.get(url, {
|
||||
headers: { Accept: 'application/json' }
|
||||
})
|
||||
this.decisionJson = res.data;
|
||||
|
||||
this.log("Recieved transcode decision:")
|
||||
this.log("Received transcode decision:");
|
||||
this.log(res.data)
|
||||
|
||||
// Print error message if transcode not possible
|
||||
// TODO: handle failure better
|
||||
let transcodeDecisionCode = res.data.MediaContainer.transcodeDecisionCode
|
||||
if (!(directPlay || transcodeDecisionCode == "1001")) {
|
||||
let transcodeDecisionCode = res.data.MediaContainer.transcodeDecisionCode;
|
||||
if (
|
||||
( typeof(transcodeDecisionCode) === 'undefined' )
|
||||
) {
|
||||
this.decisionJson.MediaContainer.transcodeDecisionCode = 'novideo';
|
||||
console.log("Audio-only file detected");
|
||||
await this.tryToGetAlbumArt();
|
||||
} else if (!(directPlay || transcodeDecisionCode == "1001")) {
|
||||
console.log(`IMPORTANT: Recieved transcode decision code ${transcodeDecisionCode}! Expected code 1001.`)
|
||||
console.log(`Error message: '${res.data.MediaContainer.transcodeDecisionText}'`)
|
||||
}
|
||||
}
|
||||
|
||||
async tryToGetAlbumArt() {
|
||||
try {
|
||||
if(this.albumArt.attempted ) {
|
||||
return;
|
||||
}
|
||||
this.albumArt.attempted = true;
|
||||
|
||||
this.log("Try to get album art:");
|
||||
let url = `${this.server.uri}${this.key}?${this.transcodingArgs}`;
|
||||
let res = await axios.get(url, {
|
||||
headers: { Accept: 'application/json' }
|
||||
});
|
||||
let mediaContainer = res.data.MediaContainer;
|
||||
if (typeof(mediaContainer) !== 'undefined') {
|
||||
for( let i = 0; i < mediaContainer.Metadata.length; i++) {
|
||||
console.log("got art: " + mediaContainer.Metadata[i].thumb );
|
||||
this.albumArt.path = `${this.server.uri}${mediaContainer.Metadata[i].thumb}?${this.transcodingArgs}`;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error when getting album art", err);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
async getDecision(directPlay) {
|
||||
try {
|
||||
await this.getDecisionUnmanaged(directPlay);
|
||||
|
||||
115
src/svg/generic-music-screen.svg
Normal file
115
src/svg/generic-music-screen.svg
Normal file
@ -0,0 +1,115 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="1920"
|
||||
height="1080"
|
||||
viewBox="0 0 507.99999 285.75001"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
|
||||
sodipodi:docname="generic-music-screen.svg"
|
||||
inkscape:export-filename="/home/vx/dev/pseudotv/resources/generic-music-screen.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="4.4585776"
|
||||
inkscape:cx="925.75604"
|
||||
inkscape:cy="448.17449"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1056"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="24"
|
||||
inkscape:window-maximized="1"
|
||||
units="px" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Capa 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(0,-11.249983)">
|
||||
<rect
|
||||
style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:3.20000029;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
|
||||
id="rect836"
|
||||
width="508"
|
||||
height="285.75"
|
||||
x="0"
|
||||
y="11.249983" />
|
||||
<g
|
||||
id="g6050"
|
||||
transform="translate(-8.4960767,30.053154)">
|
||||
<rect
|
||||
transform="rotate(0.52601418)"
|
||||
y="85.000603"
|
||||
x="214.56714"
|
||||
height="73.832573"
|
||||
width="32.814484"
|
||||
id="rect4518"
|
||||
style="opacity:1;fill:#9cbc28;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
|
||||
<rect
|
||||
transform="rotate(1.4727575)"
|
||||
style="opacity:1;fill:#289bbc;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
|
||||
id="rect4520"
|
||||
width="32.81448"
|
||||
height="73.832573"
|
||||
x="248.74632"
|
||||
y="80.901688" />
|
||||
<rect
|
||||
transform="rotate(-3.2986121)"
|
||||
y="103.78287"
|
||||
x="269.35843"
|
||||
height="73.832588"
|
||||
width="32.814476"
|
||||
id="rect4522"
|
||||
style="opacity:1;fill:#bc289b;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
|
||||
</g>
|
||||
<text
|
||||
xml:space="preserve"
|
||||
style="font-style:normal;font-weight:normal;font-size:76.95687866px;line-height:125%;font-family:Sans;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
x="147.14322"
|
||||
y="234.94209"
|
||||
id="text838"
|
||||
transform="scale(1.3642872,0.73298349)"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan836"
|
||||
x="147.14322"
|
||||
y="234.94209"
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:KacstPoster;-inkscape-font-specification:KacstPoster;stroke-width:0.26458335px">♪</tspan></text>
|
||||
<ellipse
|
||||
style="opacity:1;fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0.52916664;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
|
||||
id="path840"
|
||||
cx="240.60326"
|
||||
cy="169.0907"
|
||||
rx="15.090722"
|
||||
ry="15.089045" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
Loading…
x
Reference in New Issue
Block a user