#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:
vexorian 2021-01-23 13:03:53 -04:00
parent f32a6d1397
commit 9f194e62c6
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;
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,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;

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