Merge pull request #248 from vexorian/20210123_dev

#18  Allowing to play audio files in channels.
This commit is contained in:
vexorian 2021-01-23 13:13:04 -04:00 committed by GitHub
commit 5650d07a54
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 236 additions and 40 deletions

View File

@ -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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -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;
//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");
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 ( this.audioOnly !== true) {
ffmpegArgs.push("-r" , "24");
if ( streamUrl.errorTitle == 'offline' ) {
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;
}

View File

@ -124,11 +124,7 @@ class PlexPlayer {
return emitter;
} catch(err) {
if (err instanceof Error) {
throw err;
} else {
return Error("Error when playing plex program: " + JSON.stringify(err) );
}
return Error("Error when playing plex program: " + JSON.stringify(err) );
}
}
}

View File

@ -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);

View 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