Merge pull request #30 from vexorian/main20200814
Merge version 0.0.61 into main
This commit is contained in:
commit
ac60c32d2c
76
CODE_OF_CONDUCT.md
Normal file
76
CODE_OF_CONDUCT.md
Normal file
@ -0,0 +1,76 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as
|
||||
contributors and maintainers pledge to making participation in our project and
|
||||
our community a harassment-free experience for everyone, regardless of age, body
|
||||
size, disability, ethnicity, sex characteristics, gender identity and expression,
|
||||
level of experience, education, socio-economic status, nationality, personal
|
||||
appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment
|
||||
include:
|
||||
|
||||
* Using welcoming and inclusive language
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or electronic
|
||||
address, without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable
|
||||
behavior and are expected to take appropriate and fair corrective action in
|
||||
response to any instances of unacceptable behavior.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or
|
||||
reject comments, commits, code, wiki edits, issues, and other contributions
|
||||
that are not aligned to this Code of Conduct, or to ban temporarily or
|
||||
permanently any contributor for other behaviors that they deem inappropriate,
|
||||
threatening, offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces
|
||||
when an individual is representing the project or its community. Examples of
|
||||
representing a project or community include using an official project e-mail
|
||||
address, posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event. Representation of a project may be
|
||||
further defined and clarified by project maintainers.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported by contacting the project team at vexorian@gmail.com. All
|
||||
complaints will be reviewed and investigated and will result in a response that
|
||||
is deemed necessary and appropriate to the circumstances. The project team is
|
||||
obligated to maintain confidentiality with regard to the reporter of an incident.
|
||||
Further details of specific enforcement policies may be posted separately.
|
||||
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in good
|
||||
faith may face temporary or permanent repercussions as determined by other
|
||||
members of the project's leadership.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
|
||||
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see
|
||||
https://www.contributor-covenant.org/faq
|
||||
@ -1,4 +1,4 @@
|
||||
# dizqueTV 0.0.60
|
||||
# dizqueTV 0.0.61
|
||||
  
|
||||
|
||||
Create live TV channel streams from media on your Plex servers.
|
||||
|
||||
20
index.js
20
index.js
@ -15,7 +15,16 @@ const Plex = require('./src/plex');
|
||||
const channelCache = require('./src/channel-cache');
|
||||
const constants = require('./src/constants')
|
||||
|
||||
console.log("dizqueTV Version: " + constants.VERSION_NAME)
|
||||
console.log(
|
||||
` \\
|
||||
dizqueTV ${constants.VERSION_NAME}
|
||||
.------------.
|
||||
|###:::||| o |
|
||||
|###:::||| |
|
||||
'###:::||| o |
|
||||
'------------'
|
||||
`);
|
||||
|
||||
|
||||
for (let i = 0, l = process.argv.length; i < l; i++) {
|
||||
if ((process.argv[i] === "-p" || process.argv[i] === "--port") && i + 1 !== l)
|
||||
@ -37,7 +46,7 @@ if (!fs.existsSync(process.env.DATABASE)) {
|
||||
if(!fs.existsSync(path.join(process.env.DATABASE, 'images')))
|
||||
fs.mkdirSync(path.join(process.env.DATABASE, 'images'))
|
||||
|
||||
db.connect(process.env.DATABASE, ['channels', 'plex-servers', 'ffmpeg-settings', 'plex-settings', 'xmltv-settings', 'hdhr-settings', 'db-version'])
|
||||
db.connect(process.env.DATABASE, ['channels', 'plex-servers', 'ffmpeg-settings', 'plex-settings', 'xmltv-settings', 'hdhr-settings', 'db-version', 'client-id'])
|
||||
|
||||
initDB(db)
|
||||
|
||||
@ -65,7 +74,7 @@ let xmltvInterval = {
|
||||
if (plexServers[i].arChannels && channels.length !== 0)
|
||||
plex.RefreshChannels(channels, dvrs).then(() => { }, (err) => { console.error(err, i) })
|
||||
}).catch( (err) => {
|
||||
console.error("There was an error when fetching Plex DVRs. This means dizqueTV couldn't trigger Plex to update its TV guide." + err);
|
||||
console.log("Couldn't tell Plex to refresh channels for some reason.");
|
||||
});
|
||||
}
|
||||
}, (err) => {
|
||||
@ -123,6 +132,9 @@ function initDB(db) {
|
||||
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/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/loading-screen.png
Normal file
BIN
resources/loading-screen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@ -2,5 +2,5 @@ module.exports = {
|
||||
SLACK: 9999,
|
||||
TVGUIDE_MAXIMUM_PADDING_LENGTH_MS: 30*60*1000,
|
||||
|
||||
VERSION_NAME: "0.0.60"
|
||||
VERSION_NAME: "0.0.61"
|
||||
}
|
||||
|
||||
@ -17,15 +17,29 @@
|
||||
* but with time it will be worth it, really.
|
||||
*
|
||||
***/
|
||||
const TARGET_VERSION = 300;
|
||||
const TARGET_VERSION = 400;
|
||||
|
||||
const STEPS = [
|
||||
// [v, v2, x] : if the current version is v, call x(db), and version becomes v2
|
||||
[ 0, 100, (db) => basicDB(db) ],
|
||||
[ 100, 200, (db) => commercialsRemover(db) ],
|
||||
[ 200, 300, (db) => appNameChange(db) ],
|
||||
[ 300, 400, (db) => createDeviceId(db) ],
|
||||
]
|
||||
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
function createDeviceId(db) {
|
||||
let deviceId = db['client-id'].find();
|
||||
if (deviceId.length == 0) {
|
||||
let clientId = uuidv4().replace(/-/g,"").slice(0,16) + "-org-dizquetv-" + process.platform
|
||||
let dev = {
|
||||
clientId: clientId,
|
||||
}
|
||||
db['client-id'].save( dev );
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function appNameChange(db) {
|
||||
let xmltv = db['xmltv-settings'].find()
|
||||
@ -66,7 +80,7 @@ function basicDB(db) {
|
||||
maxPlayableResolution: "1920x1080",
|
||||
maxTranscodeResolution: "1920x1080",
|
||||
videoCodecs: 'h264,hevc,mpeg2video',
|
||||
audioCodecs: 'ac3',
|
||||
audioCodecs: 'ac3,aac',
|
||||
maxAudioChannels: '2',
|
||||
audioBoost: '100',
|
||||
enableSubtitles: false,
|
||||
@ -105,7 +119,7 @@ function basicDB(db) {
|
||||
let hdhrSettings = db['hdhr-settings'].find()
|
||||
if (hdhrSettings.length === 0) {
|
||||
db['hdhr-settings'].save({
|
||||
tunerCount: 1,
|
||||
tunerCount: 2,
|
||||
autoDiscovery: true
|
||||
})
|
||||
}
|
||||
|
||||
@ -73,8 +73,9 @@ class FFMPEG extends events.EventEmitter {
|
||||
this.spawn( {errorTitle: 'offline'}, streamStats, undefined, `${duration}ms`, true, false, 'offline', false);
|
||||
}
|
||||
async spawn(streamUrl, streamStats, startTime, duration, limitRead, enableIcon, type, isConcatPlaylist) {
|
||||
|
||||
let ffmpegArgs = [
|
||||
`-threads`, this.opts.threads,
|
||||
`-threads`, isConcatPlaylist? 1 : this.opts.threads,
|
||||
`-fflags`, `+genpts+discardcorrupt+igndts`];
|
||||
|
||||
if (limitRead === true)
|
||||
@ -94,6 +95,17 @@ class FFMPEG extends events.EventEmitter {
|
||||
|
||||
//TODO: Do something about missing audio stream
|
||||
if (!isConcatPlaylist) {
|
||||
let inputFiles = 0;
|
||||
let audioFile = -1;
|
||||
let videoFile = -1;
|
||||
let overlayFile = -1;
|
||||
if ( typeof(streamUrl.errorTitle) === 'undefined') {
|
||||
ffmpegArgs.push(`-i`, streamUrl);
|
||||
videoFile = inputFiles++;
|
||||
audioFile = videoFile;
|
||||
}
|
||||
|
||||
|
||||
// When we have an individual stream, there is a pipeline of possible
|
||||
// filters to apply.
|
||||
//
|
||||
@ -108,8 +120,8 @@ class FFMPEG extends events.EventEmitter {
|
||||
// Initially, videoComplex does nothing besides assigning the label
|
||||
// to the input stream
|
||||
var videoIndex = 'v';
|
||||
var audioComplex = `;[0:${audioIndex}]anull[audio]`;
|
||||
var videoComplex = `;[0:${videoIndex}]null[video]`;
|
||||
var audioComplex = `;[${audioFile}:${audioIndex}]anull[audio]`;
|
||||
var videoComplex = `;[${videoFile}:${videoIndex}]null[video]`;
|
||||
// Depending on the options we will apply multiple filters
|
||||
// each filter modifies the current video stream. Adds a filter to
|
||||
// the videoComplex variable. The result of the filter becomes the
|
||||
@ -197,11 +209,10 @@ class FFMPEG extends events.EventEmitter {
|
||||
audioComplex += ';[audioy]arealtime[audiox]';
|
||||
currentVideo = "[videox]";
|
||||
currentAudio = "[audiox]";
|
||||
} else {
|
||||
ffmpegArgs.push(`-i`, streamUrl);
|
||||
}
|
||||
if (doOverlay) {
|
||||
ffmpegArgs.push(`-i`, `${this.channel.icon}` );
|
||||
overlayFile = inputFiles++;
|
||||
}
|
||||
|
||||
// Resolution fix: Add scale filter, current stream becomes [siz]
|
||||
@ -223,7 +234,7 @@ class FFMPEG extends events.EventEmitter {
|
||||
if (this.channel.iconDuration > 0)
|
||||
icnDur = `:enable='between(t,0,${this.channel.iconDuration})'`
|
||||
|
||||
videoComplex += `;[1:v]scale=${this.channel.iconWidth}:-1[icn];${currentVideo}[icn]overlay=${posAry[this.channel.iconPosition]}${icnDur}[comb]`
|
||||
videoComplex += `;[${overlayFile}:v]scale=${this.channel.iconWidth}:-1[icn];${currentVideo}[icn]overlay=${posAry[this.channel.iconPosition]}${icnDur}[comb]`
|
||||
currentVideo = '[comb]';
|
||||
}
|
||||
if (this.volumePercent != 100) {
|
||||
@ -250,14 +261,14 @@ class FFMPEG extends events.EventEmitter {
|
||||
transcodeVideo = true; //this is useful so that it adds some lines below
|
||||
filterComplex += videoComplex;
|
||||
} else {
|
||||
currentVideo = `0:${videoIndex}`;
|
||||
currentVideo = `${videoFile}:${videoIndex}`;
|
||||
}
|
||||
// same with audio:
|
||||
if (currentAudio != '[audio]') {
|
||||
transcodeAudio = true;
|
||||
filterComplex += audioComplex;
|
||||
} else {
|
||||
currentAudio = `0:${audioIndex}`;
|
||||
currentAudio = `${audioFile}:${audioIndex}`;
|
||||
}
|
||||
|
||||
//If there is a filter complex, add it.
|
||||
@ -309,7 +320,7 @@ class FFMPEG extends events.EventEmitter {
|
||||
} else {
|
||||
//Concat stream is simpler and should always copy the codec
|
||||
ffmpegArgs.push(
|
||||
`-probesize`, `100000000`,
|
||||
`-probesize`, 32 /*`100000000`*/,
|
||||
`-i`, streamUrl,
|
||||
`-map`, `0:v`,
|
||||
`-map`, `0:${audioIndex}`,
|
||||
@ -321,7 +332,7 @@ class FFMPEG extends events.EventEmitter {
|
||||
ffmpegArgs.push(`-metadata`,
|
||||
`service_provider="dizqueTV"`,
|
||||
`-metadata`,
|
||||
`service_name="${this.channel.name}`,
|
||||
`service_name="${this.channel.name}"`,
|
||||
`-f`, `mpegts`);
|
||||
|
||||
//t should be before output
|
||||
|
||||
@ -13,6 +13,11 @@ class OfflinePlayer {
|
||||
constructor(error, context) {
|
||||
this.context = context;
|
||||
this.error = error;
|
||||
if (context.isLoading === true) {
|
||||
context.channel = JSON.parse( JSON.stringify(context.channel) );
|
||||
context.channel.offlinePicture = `http://localhost:${process.env.PORT}/images/loading-screen.png`;
|
||||
context.channel.offlineSoundtrack = undefined;
|
||||
}
|
||||
this.ffmpeg = new FFMPEG(context.ffmpegSettings, context.channel);
|
||||
}
|
||||
|
||||
|
||||
@ -10,6 +10,8 @@ const EventEmitter = require('events');
|
||||
const helperFuncs = require('./helperFuncs')
|
||||
const FFMPEG = require('./ffmpeg')
|
||||
|
||||
let USED_CLIENTS = {};
|
||||
|
||||
class PlexPlayer {
|
||||
|
||||
constructor(context) {
|
||||
@ -17,9 +19,17 @@ class PlexPlayer {
|
||||
this.ffmpeg = null;
|
||||
this.plexTranscoder = null;
|
||||
this.killed = false;
|
||||
let coreClientId = this.context.db['client-id'].find()[0].clientId;
|
||||
let i = 0;
|
||||
while ( USED_CLIENTS[coreClientId+"-"+i]===true) {
|
||||
i++;
|
||||
}
|
||||
this.clientId = coreClientId+"-"+i;
|
||||
USED_CLIENTS[this.clientId] = true;
|
||||
}
|
||||
|
||||
cleanUp() {
|
||||
USED_CLIENTS[this.clientId] = false;
|
||||
this.killed = true;
|
||||
if (this.plexTranscoder != null) {
|
||||
this.plexTranscoder.stopUpdatingPlex();
|
||||
@ -39,7 +49,7 @@ class PlexPlayer {
|
||||
|
||||
try {
|
||||
let plexSettings = db['plex-settings'].find()[0];
|
||||
let plexTranscoder = new PlexTranscoder(plexSettings, channel, lineupItem);
|
||||
let plexTranscoder = new PlexTranscoder(this.clientId, plexSettings, channel, lineupItem);
|
||||
this.plexTranscoder = plexTranscoder;
|
||||
let enableChannelIcon = this.context.enableChannelIcon;
|
||||
let ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options
|
||||
|
||||
@ -2,12 +2,12 @@ const { v4: uuidv4 } = require('uuid');
|
||||
const axios = require('axios');
|
||||
|
||||
class PlexTranscoder {
|
||||
constructor(settings, channel, lineupItem) {
|
||||
constructor(clientId, settings, channel, lineupItem) {
|
||||
this.session = uuidv4()
|
||||
|
||||
this.device = "channel-" + channel.number;
|
||||
this.deviceName = this.device;
|
||||
this.clientIdentifier = this.session.replace(/-/g,"").slice(0,16) + "-org-dizquetv-" + process.platform;
|
||||
this.clientIdentifier = clientId;
|
||||
this.product = "dizqueTV";
|
||||
|
||||
this.settings = settings
|
||||
@ -60,7 +60,10 @@ class PlexTranscoder {
|
||||
stream.directPlay = true;
|
||||
}
|
||||
}
|
||||
if (stream.directPlay) {
|
||||
if (stream.directPlay || this.isAV1() ) {
|
||||
if (! stream.directPlay) {
|
||||
this.log("Plex doesn't support av1, so we are forcing direct play, including for audio because otherwise plex breaks the stream.")
|
||||
}
|
||||
this.log("Direct play forced or native paths enabled")
|
||||
stream.directPlay = true
|
||||
this.setTranscodingArgs(stream.directPlay, true, false)
|
||||
@ -78,14 +81,15 @@ class PlexTranscoder {
|
||||
await this.getDecision(stream.directPlay);
|
||||
stream.streamUrl = `${this.transcodeUrlBase}${this.transcodingArgs}`
|
||||
} else {
|
||||
//This case sounds complex. Apparently plex is sending us just the audio, so we would need to get the video in a separate stream.
|
||||
this.log("Decision: Direct stream. Audio is being transcoded")
|
||||
stream.separateVideoStream = (this.settings.streamPath === 'direct') ? this.file : this.plexFile;
|
||||
stream.streamUrl = `${this.transcodeUrlBase}${this.transcodingArgs}`
|
||||
}
|
||||
stream.streamStats = this.getVideoStats();
|
||||
|
||||
// use correct audio stream if direct play
|
||||
let audioIndex = await this.getAudioIndex();
|
||||
stream.streamStats.audioIndex = (stream.directPlay) ? audioIndex : 'a'
|
||||
stream.streamStats.audioIndex = (stream.directPlay) ? ( await this.getAudioIndex() ) : 'a'
|
||||
|
||||
this.log(stream)
|
||||
|
||||
@ -178,6 +182,14 @@ lang=en`
|
||||
}
|
||||
}
|
||||
|
||||
isAV1() {
|
||||
try {
|
||||
return this.getVideoStats().videoCodec === 'av1';
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
isDirectPlay() {
|
||||
try {
|
||||
return this.getVideoStats().videoDecision === "copy" && this.getVideoStats().audioDecision === "copy";
|
||||
|
||||
@ -32,6 +32,11 @@ class ProgramPlayer {
|
||||
if (program.err instanceof Error) {
|
||||
console.log("About to play error stream");
|
||||
this.delegate = new OfflinePlayer(true, context);
|
||||
} else if (program.type === 'loading') {
|
||||
console.log("About to play loading stream");
|
||||
/* loading */
|
||||
context.isLoading = true;
|
||||
this.delegate = new OfflinePlayer(false, context);
|
||||
} else if (program.type === 'offline') {
|
||||
console.log("About to play offline stream");
|
||||
/* offline */
|
||||
|
||||
107
src/svg/loading-screen.svg
Normal file
107
src/svg/loading-screen.svg
Normal file
@ -0,0 +1,107 @@
|
||||
<?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="loading-screen.svg"
|
||||
inkscape:export-filename="/home/vx/dev/pseudotv/resources/loading-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="0.37433674"
|
||||
inkscape:cx="1004.7641"
|
||||
inkscape:cy="545.11626"
|
||||
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:10.58333302px;line-height:125%;font-family:Sans;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
x="228.70648"
|
||||
y="210.99644"
|
||||
id="text939"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan937"
|
||||
x="228.70648"
|
||||
y="210.99644"
|
||||
style="fill:#f9f9f9;stroke-width:0.26458332px">Loading...</tspan></text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
58
src/video.js
58
src/video.js
@ -143,6 +143,11 @@ function video(db) {
|
||||
res.status(404).send("Channel doesn't exist")
|
||||
return
|
||||
}
|
||||
let isLoading = false;
|
||||
if ( (typeof req.query.first !== 'undefined') && (req.query.first=='0') ) {
|
||||
isLoading = true;
|
||||
}
|
||||
|
||||
let isFirst = false;
|
||||
if ( (typeof req.query.first !== 'undefined') && (req.query.first=='1') ) {
|
||||
isFirst = true;
|
||||
@ -164,7 +169,14 @@ function video(db) {
|
||||
// Get video lineup (array of video urls with calculated start times and durations.)
|
||||
let t0 = (new Date()).getTime();
|
||||
let lineupItem = channelCache.getCurrentLineupItem( channel.number, t0);
|
||||
if (lineupItem == null) {
|
||||
if (isLoading) {
|
||||
lineupItem = {
|
||||
type: 'loading',
|
||||
streamDuration: 1000,
|
||||
duration: 1000,
|
||||
start: 0,
|
||||
};
|
||||
} else if (lineupItem == null) {
|
||||
let prog = helperFuncs.getCurrentProgramAndTimeElapsed(t0, channel)
|
||||
|
||||
if (prog.program.isOffline && channel.programs.length == 1) {
|
||||
@ -207,7 +219,9 @@ function video(db) {
|
||||
}
|
||||
console.log("=========================================================");
|
||||
|
||||
channelCache.recordPlayback(channel.number, t0, lineupItem);
|
||||
if (! isLoading) {
|
||||
channelCache.recordPlayback(channel.number, t0, lineupItem);
|
||||
}
|
||||
|
||||
let playerContext = {
|
||||
lineupItem : lineupItem,
|
||||
@ -280,6 +294,41 @@ function video(db) {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
router.get('/m3u8', (req, res) => {
|
||||
res.type('text')
|
||||
|
||||
// Check if channel queried is valid
|
||||
if (typeof req.query.channel === 'undefined') {
|
||||
res.status(500).send("No Channel Specified")
|
||||
return
|
||||
}
|
||||
|
||||
let channelNum = parseInt(req.query.channel, 10)
|
||||
let channel = channelCache.getChannelConfig(db, channelNum );
|
||||
if (channel.length === 0) {
|
||||
res.status(500).send("Channel doesn't exist")
|
||||
return
|
||||
}
|
||||
|
||||
// Maximum number of streams to concatinate beyond channel starting
|
||||
// If someone passes this number then they probably watch too much television
|
||||
let maxStreamsToPlayInARow = 100;
|
||||
|
||||
var data = "#EXTM3U\n"
|
||||
|
||||
let ffmpegSettings = db['ffmpeg-settings'].find()[0]
|
||||
|
||||
if ( ffmpegSettings.enableFFMPEGTranscoding === true) {
|
||||
data += `${req.protocol}://${req.get('host')}/stream?channel=${channelNum}&first=0\n`;
|
||||
}
|
||||
data += `${req.protocol}://${req.get('host')}/stream?channel=${channelNum}&first=1\n`
|
||||
for (var i = 0; i < maxStreamsToPlayInARow - 1; i++) {
|
||||
data += `${req.protocol}://${req.get('host')}/stream?channel=${channelNum}\n`
|
||||
}
|
||||
|
||||
res.send(data)
|
||||
})
|
||||
router.get('/playlist', (req, res) => {
|
||||
res.type('text')
|
||||
|
||||
@ -302,6 +351,11 @@ function video(db) {
|
||||
|
||||
var data = "ffconcat version 1.0\n"
|
||||
|
||||
let ffmpegSettings = db['ffmpeg-settings'].find()[0]
|
||||
|
||||
if ( ffmpegSettings.enableFFMPEGTranscoding === true) {
|
||||
data += `file 'http://localhost:${process.env.PORT}/stream?channel=${channelNum}&first=0'\n`;
|
||||
}
|
||||
data += `file 'http://localhost:${process.env.PORT}/stream?channel=${channelNum}&first=1'\n`
|
||||
for (var i = 0; i < maxStreamsToPlayInARow - 1; i++) {
|
||||
data += `file 'http://localhost:${process.env.PORT}/stream?channel=${channelNum}'\n`
|
||||
|
||||
@ -796,6 +796,13 @@ module.exports = function ($timeout, $location) {
|
||||
selectedPrograms[i].commercials = []
|
||||
scope.channel.programs = scope.channel.programs.concat(selectedPrograms)
|
||||
updateChannelDuration()
|
||||
setTimeout(
|
||||
() => {
|
||||
scope.$apply( () => {
|
||||
scope.minProgramIndex = Math.max(0, scope.channel.programs.length - 100);
|
||||
} )
|
||||
}, 0
|
||||
);
|
||||
}
|
||||
scope.selectProgram = (index) => {
|
||||
scope.selectedProgram = index;
|
||||
|
||||
@ -13,7 +13,17 @@ module.exports = function (plex, dizquetv, $timeout) {
|
||||
if ( typeof(scope.limit) == 'undefined') {
|
||||
scope.limit = 1000000000;
|
||||
}
|
||||
scope.pending = 0;
|
||||
scope.allowedIndexes = [];
|
||||
for (let i = -10; i <= -1; i++) {
|
||||
scope.allowedIndexes.push(i);
|
||||
}
|
||||
scope.selection = []
|
||||
scope.wait = (t) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
$timeout(resolve,t);
|
||||
});
|
||||
}
|
||||
scope.selectServer = function (server) {
|
||||
scope.plexServer = server
|
||||
updateLibrary(server)
|
||||
@ -31,15 +41,36 @@ module.exports = function (plex, dizquetv, $timeout) {
|
||||
scope.visible = false
|
||||
}
|
||||
}
|
||||
scope.selectItem = (item) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
$timeout(async () => {
|
||||
item.streams = await plex.getStreams(scope.plexServer, item.key)
|
||||
scope.selection.push(JSON.parse(angular.toJson(item)))
|
||||
scope.$apply()
|
||||
resolve()
|
||||
}, 0)
|
||||
})
|
||||
scope.selectItem = async (item, single) => {
|
||||
await scope.wait(0);
|
||||
scope.pending += 1;
|
||||
try {
|
||||
item.streams = await plex.getStreams(scope.plexServer, item.key)
|
||||
scope.selection.push(JSON.parse(angular.toJson(item)))
|
||||
} finally {
|
||||
scope.pending -= 1;
|
||||
}
|
||||
if (single) {
|
||||
scope.$apply()
|
||||
}
|
||||
}
|
||||
scope.selectLibrary = async (library) => {
|
||||
await scope.fillNestedIfNecessary(library);
|
||||
let p = library.nested.length;
|
||||
scope.pending += library.nested.length;
|
||||
try {
|
||||
for (let i = 0; i < library.nested.length; i++) {
|
||||
//await scope.selectItem( library.nested[i] );
|
||||
if (library.nested[i].type !== 'collection') {
|
||||
await scope.selectShow( library.nested[i] );
|
||||
}
|
||||
scope.pending -= 1;
|
||||
p -= 1;
|
||||
}
|
||||
} finally {
|
||||
scope.pending -= p;
|
||||
scope.$apply()
|
||||
}
|
||||
}
|
||||
dizquetv.getPlexServers().then((servers) => {
|
||||
if (servers.length === 0) {
|
||||
@ -66,10 +97,14 @@ module.exports = function (plex, dizquetv, $timeout) {
|
||||
console.log(err)
|
||||
})
|
||||
}
|
||||
scope.getNested = (list) => {
|
||||
scope.fillNestedIfNecessary = async (x, isLibrary) => {
|
||||
if ( (typeof(x.nested) === 'undefined') && (x.type !== 'collection') ) {
|
||||
x.nested = await plex.getNested(scope.plexServer, x.key, isLibrary);
|
||||
}
|
||||
}
|
||||
scope.getNested = (list, isLibrary) => {
|
||||
$timeout(async () => {
|
||||
if (typeof list.nested === 'undefined')
|
||||
list.nested = await plex.getNested(scope.plexServer, list.key)
|
||||
await scope.fillNestedIfNecessary(list, isLibrary);
|
||||
list.collapse = !list.collapse
|
||||
scope.$apply()
|
||||
}, 0)
|
||||
@ -78,34 +113,53 @@ module.exports = function (plex, dizquetv, $timeout) {
|
||||
scope.selectSeason = (season) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
$timeout(async () => {
|
||||
if (typeof season.nested === 'undefined')
|
||||
season.nested = await plex.getNested(scope.plexServer, season.key)
|
||||
for (let i = 0, l = season.nested.length; i < l; i++)
|
||||
await scope.selectItem(season.nested[i])
|
||||
scope.$apply()
|
||||
resolve()
|
||||
await scope.fillNestedIfNecessary(season);
|
||||
let p = season.nested.length;
|
||||
scope.pending += p;
|
||||
try {
|
||||
for (let i = 0, l = season.nested.length; i < l; i++) {
|
||||
await scope.selectItem(season.nested[i], false)
|
||||
scope.pending -= 1;
|
||||
p -= 1;
|
||||
}
|
||||
resolve();
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
} finally {
|
||||
scope.pending -= p;
|
||||
scope.$apply()
|
||||
}
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
scope.selectShow = (show) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
$timeout(async () => {
|
||||
if (typeof show.nested === 'undefined')
|
||||
show.nested = await plex.getNested(scope.plexServer, show.key)
|
||||
for (let i = 0, l = show.nested.length; i < l; i++)
|
||||
await scope.selectSeason(show.nested[i])
|
||||
scope.$apply()
|
||||
resolve()
|
||||
await scope.fillNestedIfNecessary(show);
|
||||
let p = show.nested.length;
|
||||
scope.pending += p;
|
||||
try {
|
||||
for (let i = 0, l = show.nested.length; i < l; i++) {
|
||||
await scope.selectSeason(show.nested[i])
|
||||
scope.pending -= 1;
|
||||
p -= 1;
|
||||
}
|
||||
resolve();
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
} finally {
|
||||
scope.pending -= p;
|
||||
scope.$apply()
|
||||
}
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
scope.selectPlaylist = async (playlist) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
$timeout(async () => {
|
||||
if (typeof playlist.nested === 'undefined')
|
||||
playlist.nested = await plex.getNested(scope.plexServer, playlist.key)
|
||||
await scope.fillNestedIfNecessary(playlist);
|
||||
for (let i = 0, l = playlist.nested.length; i < l; i++)
|
||||
await scope.selectItem(playlist.nested[i])
|
||||
await scope.selectItem(playlist.nested[i], false)
|
||||
scope.$apply()
|
||||
resolve()
|
||||
}, 0)
|
||||
|
||||
@ -72,6 +72,7 @@
|
||||
font-size: 80%;
|
||||
font-weight: 400;
|
||||
font-family: monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.program-row {
|
||||
align-items: start;
|
||||
@ -92,4 +93,26 @@
|
||||
font-size: .875rem;
|
||||
line-height: 1.0;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.loader {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
border: 0.3em solid #f3f3f3;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
border-top: 0.25em solid #3498db;
|
||||
-webkit-animation: spin 2s linear infinite; /* Safari */
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
|
||||
/* Safari */
|
||||
@-webkit-keyframes spin {
|
||||
0% { -webkit-transform: rotate(0deg); }
|
||||
100% { -webkit-transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
@ -66,11 +66,13 @@
|
||||
<label>Video Encoder</label>
|
||||
<input type="text" class="form-control form-control-sm" ng-model="settings.videoEncoder" ria-describedby="videoEncoderHelp"/>
|
||||
<small id="videoEncoderHelp" class="form-text text-muted">Some possible values are:</small>
|
||||
<small id="videoEncoderHelp" class="form-text text-muted">Intel Quick Sync: h264_qsv, mpeg2_qsv</small>
|
||||
<small id="videoEncoderHelp" class="form-text text-muted">NVIDIA: GPU: h264_nvenc</small>
|
||||
<small id="videoEncoderHelp" class="form-text text-muted">h264 with Intel Quick Sync: h264_qsv</small>
|
||||
<small id="videoEncoderHelp" class="form-text text-muted">MPEG2 with Intel Quick Sync: mpeg2_qsv</small>
|
||||
<small id="videoEncoderHelp" class="form-text text-muted">NVIDIA: h264_nvenc</small>
|
||||
<small id="videoEncoderHelp" class="form-text text-muted">MPEG2: mpeg2video (default)</small>
|
||||
<small id="videoEncoderHelp" class="form-text text-muted">H264: libx264</small>
|
||||
<small id="videoEncoderHelp" class="form-text text-muted">MacOS: h264_videotoolbox</small>
|
||||
<small id="videoEncoderHelp" class="form-text text-muted">Hardware encoders are not supported in docker for now</small>
|
||||
</div>
|
||||
<div class="col-sm-1" />
|
||||
<div class="col-sm-4">
|
||||
|
||||
@ -33,16 +33,19 @@
|
||||
<hr />
|
||||
<ul class="list-group list-group-root plex-panel" ng-init="setHeight = {'height': height + 'px'}" ng-style="setHeight" lazy-img-container>
|
||||
<li class="list-group-item" ng-repeat="a in libraries">
|
||||
<div class="{{ displayImages ? 'w_images' : 'wo_images' }}" ng-click="getNested(a);">
|
||||
<div class="flex-container {{ displayImages ? 'w_images' : 'wo_images' }}" ng-click="getNested(a, true);">
|
||||
<span class="fa {{ a.collapse ? 'fa-chevron-down' : 'fa-chevron-right' }} tab"></span>
|
||||
<img ng-if="displayImages" lazy-img="{{a.icon}}" />
|
||||
<span>{{a.title}}</span><!-- Library -->
|
||||
<span ng-if="a.type === 'show' || a.type === 'movie'" class="flex-pull-right" ng-click='$event.stopPropagation(); selectLibrary(a)'>
|
||||
<span class="fa fa-plus btn"></span>
|
||||
</span>
|
||||
</div>
|
||||
<ul ng-if="a.collapse" class="list-group">
|
||||
<li class="list-group-item {{ b.type !== 'movie' ? 'list-group-item-secondary' : 'list-group-item-video' }}"
|
||||
ng-repeat="b in a.nested">
|
||||
<div class="flex-container"
|
||||
ng-click="b.type !== 'movie' ? getNested(b) : selectItem(b)">
|
||||
ng-click="b.type !== 'movie' ? getNested(b) : selectItem(b, true)">
|
||||
<span ng-if="b.type === 'movie'" class="fa fa-plus-circle tab"></span>
|
||||
<span ng-if="b.type !== 'movie'" class="tab"></span>
|
||||
<span ng-if="b.type !== 'movie'" class="fa {{ b.collapse ? 'fa-chevron-down' : 'fa-chevron-right' }} tab"></span>
|
||||
@ -54,7 +57,7 @@
|
||||
<span ng-if="b.type === 'playlist'" class="flex-pull-right" ng-click="$event.stopPropagation(); selectPlaylist(b);">
|
||||
<span class="fa fa-plus btn"></span>
|
||||
</span>
|
||||
<span ng-if="b.type === 'show'" class="flex-pull-right" ng-click="$event.stopPropagation(); selectShow(b);">
|
||||
<span ng-if="b.type === 'show' || b.type === 'collection'" class="flex-pull-right" ng-click="$event.stopPropagation(); selectShow(b);">
|
||||
<span class="fa fa-plus btn"></span>
|
||||
</span>
|
||||
</div>
|
||||
@ -62,7 +65,7 @@
|
||||
<li ng-repeat="c in b.nested"
|
||||
class="list-group-item {{ c.type !== 'movie' && c.type !== 'episode' ? 'list-group-item-dark' : 'list-group-item-video' }}">
|
||||
<div class="flex-container"
|
||||
ng-click="c.type !== 'movie' && c.type !== 'episode' ? getNested(c) : selectItem(c)">
|
||||
ng-click="c.type !== 'movie' && c.type !== 'episode' ? getNested(c) : selectItem(c, true)">
|
||||
<span ng-if="c.type === 'movie' || c.type === 'episode'"
|
||||
class="fa fa-plus-circle tab"></span>
|
||||
<span ng-if="c.type !== 'movie' && c.type !== 'episode'"
|
||||
@ -85,7 +88,7 @@
|
||||
<ul ng-if="c.collapse" class="list-group">
|
||||
<li class="list-group-item list-group-item-video"
|
||||
ng-repeat="d in c.nested">
|
||||
<div class="flex-container" ng-click="selectItem(d)">
|
||||
<div class="flex-container" ng-click="selectItem(d, true)">
|
||||
<span class="fa fa-plus-circle tab"></span>
|
||||
<img ng-if="displayImages" lazy-img="{{d.episodeIcon}}" />
|
||||
E{{ d.episode.toString().padStart(2,'0')}} - {{d.title}}
|
||||
@ -101,13 +104,15 @@
|
||||
</li>
|
||||
</ul>
|
||||
<hr/>
|
||||
<h6>Selected Items</h6>
|
||||
<div class="loader" ng-if="pending > 0" ></div> <h6 style='display:inline-block'>Selected Items</h6>
|
||||
|
||||
<div class="text-info small" ng-show='selection.length > 10'>{{ selection.length }} elements added in total. Only the last 10 elements are displayed:</div>
|
||||
<ul class="list-group list-group-root" style="height: 180px; overflow-y: scroll" dnd-list="selection" scroll-glue>
|
||||
<div ng-if="selection.length === 0">Select media items from your plex library above.</div>
|
||||
<li class="list-group-item" ng-repeat="x in selection" style="cursor:default;" dnd-draggable="x" dnd-moved="selection.splice($index, 1)" dnd-effect-allowed="move">
|
||||
{{ (x.type !== 'episode') ? x.title : (x.showTitle + ' - S' + x.season.toString().padStart(2,'0') + 'E' + x.episode.toString().padStart(2,'0'))}}
|
||||
<li ng-if="selection.length + x >= 0" class="list-group-item" ng-repeat="x in allowedIndexes" style="cursor:default;" dnd-draggable="x" dnd-moved="selection.splice(selection.length + x, 1)" dnd-effect-allowed="move">
|
||||
{{ (selection[selection.length + x].type !== 'episode') ? selection[selection.length + x].title : (selection[selection.length + x].showTitle + ' - S' + selection[selection.length + x].season.toString().padStart(2,'0') + 'E' + selection[selection.length + x].episode.toString().padStart(2,'0'))}}
|
||||
<span class="pull-right">
|
||||
<span class="btn fa fa-trash" ng-click="selection.splice($index,1)"></span>
|
||||
<span class="btn fa fa-trash" ng-click="selection.splice(selection.length + x,1)"></span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
const Plex = require('../../src/plex');
|
||||
|
||||
module.exports = function ($http, $window, $interval) {
|
||||
return {
|
||||
let exported = {
|
||||
login: async () => {
|
||||
const headers = {
|
||||
'Accept': 'application/json',
|
||||
@ -119,11 +119,12 @@ module.exports = function ($http, $window, $interval) {
|
||||
return streams
|
||||
})
|
||||
},
|
||||
getNested: async (server, key) => {
|
||||
getNested: async (server, key, includeCollections) => {
|
||||
var client = new Plex(server)
|
||||
const res = await client.Get(key)
|
||||
var nested = []
|
||||
var seenFiles = {};
|
||||
var collections = {};
|
||||
for (let i = 0, l = typeof res.Metadata !== 'undefined' ? res.Metadata.length : 0; i < l; i++) {
|
||||
// Skip any videos (movie or episode) without a duration set...
|
||||
if (typeof res.Metadata[i].duration === 'undefined' && (res.Metadata[i].type === "episode" || res.Metadata[i].type === "movie"))
|
||||
@ -178,11 +179,65 @@ module.exports = function ($http, $window, $interval) {
|
||||
program.episode = 1
|
||||
program.season = 1
|
||||
}
|
||||
if (typeof (res.Metadata[i].Collection) !== 'undefined') {
|
||||
let coll = res.Metadata[i].Collection;
|
||||
for (let j = 0; j < coll.length; j++) {
|
||||
let tag = coll[j].tag;
|
||||
if ( (typeof(tag)!== "undefined") && (tag.length > 0) ) {
|
||||
let collection = collections[tag];
|
||||
if (typeof(collection) === 'undefined') {
|
||||
collection = [];
|
||||
collections[tag] = collection;
|
||||
}
|
||||
collection.push( program );
|
||||
}
|
||||
}
|
||||
}
|
||||
nested.push(program)
|
||||
}
|
||||
if (includeCollections === true) {
|
||||
let nestedCollections = [];
|
||||
let keys = [];
|
||||
Object.keys(collections).forEach(function(key,index) {
|
||||
keys.push(key);
|
||||
});
|
||||
for (let k = 0; k < keys.length; k++) {
|
||||
let key = keys[k];
|
||||
if (collections[key].length <= 1) {
|
||||
//it's pointless to include it.
|
||||
continue;
|
||||
}
|
||||
let collection = {
|
||||
title: key,
|
||||
key: "#collection",
|
||||
icon : "",
|
||||
type : "collection",
|
||||
nested: collections[key],
|
||||
}
|
||||
if (res.viewGroup === 'show') {
|
||||
collection.title = collection.title + " Collection";
|
||||
//nest the seasons directly because that's way too many depth levels already
|
||||
let shows = collection.nested;
|
||||
let collectionContents = [];
|
||||
for (let i = 0; i < shows.length; i++) {
|
||||
let seasons = await exported.getNested(server, shows[i].key, false);
|
||||
for (let j = 0; j < seasons.length; j++) {
|
||||
seasons[j].title = shows[i].title + " - " + seasons[j].title;
|
||||
collectionContents.push(seasons[j]);
|
||||
}
|
||||
}
|
||||
collection.nested = collectionContents;
|
||||
}
|
||||
nestedCollections.push( collection );
|
||||
}
|
||||
nested = nestedCollections.concat(nested);
|
||||
}
|
||||
|
||||
|
||||
return nested
|
||||
}
|
||||
}
|
||||
return exported;
|
||||
}
|
||||
|
||||
function msToTime(duration) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user