Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix YouTube Livestreams #7

Closed
wants to merge 32 commits into from

Conversation

FireController1847
Copy link

@FireController1847 FireController1847 commented Aug 31, 2017

YouTube Livestreams currently start very early in the livestream, due to the way that YouTube's m3u8 works. This will cause it to read the stream from the ~4h mark (if the stream is archived).

Fixes fent/node-ytdl-core#157
Fixes fent/node-ytdl-core#263

@FireController1847
Copy link
Author

FireController1847 commented Aug 31, 2017

This is my current prototype of a working solution. Please edit it and make it match whatever you'd like.

Example usage:

# In line 68 on fent/node-ytdl-core, make these changes.
*  if (format.live) {
*    var req = m3u8stream(url, {
*      chunkReadahead: +info.live_chunk_readahead,
*      requestOptions: options.requestOptions,
+      ytdlOptions: {isYtdlCore: true}
*   });
*   req.on('error', stream.emit.bind(stream, 'error'));
*   stream.destroy = req.end.bind(req);

Edit: Also if somebody could do the tests for me that'd be great. I'm not exactly sure what it outputs at the moment so I can't write one. Also I'll make the PR on ytdl-core when we decide how we want this to be done.

@codecov
Copy link

codecov bot commented Aug 31, 2017

Codecov Report

Merging #7 into master will decrease coverage by 19.64%.
The diff coverage is 36.53%.

Impacted file tree graph

@@             Coverage Diff             @@
##           master       #7       +/-   ##
===========================================
- Coverage     100%   80.35%   -19.65%     
===========================================
  Files           3        3               
  Lines         120      168       +48     
  Branches       22       31        +9     
===========================================
+ Hits          120      135       +15     
- Misses          0       33       +33
Impacted Files Coverage Δ
lib/m3u8-parser.js 56.6% <32.35%> (-43.4%) ⬇️
lib/index.js 87.8% <44.44%> (-12.2%) ⬇️
lib/queue.js 100% <0%> (ø) ⬆️

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 4a1e384...6f8bf07. Read the comment docs.

Copy link
Author

@FireController1847 FireController1847 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added comments explaining everything I did and what it does. Might help you all to understand what I did specifically.

var timeout = setTimeout(function() {
self.ytdlSavedLines = [];
return self.emit('ytdl-end');
clearTimeout(timeout); // Just in case timeout is still set, we want to make sure it's destroyed.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do realize this line is now redundant, I added it when there wasn't a return for the self.emit. I was having issues in this area, though, with it requesting 4-5 times a second. I don't know if this still happens, but I think it is happening very slowly. The longer you listen, the more you hear stuttering. Try adding a console.log here to see how many times it calls. It should only all every 5 seconds (or however long is).

TL;DR: I believe I ended up creating a memory leak right here.

lib/index.js Outdated
@@ -56,12 +57,16 @@ module.exports = function(playlistURL, options) {
}

var tid;
var rpts;
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rpts means "Repeat Times". It's the amount of times refreshPlaylist() has been called when ytdl-error runs. If it loops more than 5 times from ytdl-errors in a row, then it's probably crashing and time to stop.

lib/index.js Outdated
@@ -56,12 +57,16 @@ module.exports = function(playlistURL, options) {
}

var tid;
var rpts;
var ytft = true;
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ytft means "YouTube First Time". This is required, as we have to let the m3u8 know if it's the first time. More information on this will be in my comment on the actual part that handles time.

lib/index.js Outdated
@@ -74,6 +79,21 @@ module.exports = function(playlistURL, options) {
totalItems++;
requestQueue.push(item, onQueuedEnd);
});
parser.on('ytdl-end', function() {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do not want to call the normal end, as that sets a 10 minute timeout, when we need to call every 5 seconds. So, this custom function will do just that. It deletes the parser just in case that causes a memory leak (because we create a new one every loop and don't "return"), and then calls refreshPlaylist again. It finally sets ytft to false and rpts to 0 (see above).

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you notice the program building up memory? If so, don't think it would be from here, parser is not used or saved outside of this function. The delete operator also cannot delete variables, it only deletes keys from an object. https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/delete

lib/index.js Outdated
rpts = 0;
ytft = false;
});
parser.on('ytdl-error', function() {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the parser has an internal error when parsing time or something like that, this will be called. If rpts (see above) is more than or equal to 5, then error out and stop with 'Too Many Requests' signaling an internal error. Optionally, we might want to add a second argument called "err" as when this event is called it does come with reasoning.

} else {
// Save the last line in case it has been broken up.
lastLine = line;
if (this.isYtdlCore) {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a check for if you want the old functionality or the YouTube functionality. Might want to rename the variable, as I'm sure this isn't just used in YtdlCore.

// Save the last line in case it has been broken up.
lastLine = line;
if (this.isYtdlCore) {
for (var i = 0; i < lines.length; i++) {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Synchronously loop through all of the lines instead of Asynchronously, that way we are sure that all of the packets got into the array before the 'end' event is called from the ReadableStream.

for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (i < lines.length - 1) {
self.ytdlSavedLines.push(line);
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Push every single packet into the array individually, instead of pushing sections of packets into the array.

}
});
} else {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keep the old functionality just in case somebody wants to use this for something other than Ytdl-Core. m3u8 is very complicated.

callback();
};

this.on('end', function() {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the end event is called, due to the fact we synchronously pushed the packets into the array we can be certain that every packet is now in the array, and we can now parse it manually using the function (of course, checking if isYtdlCore is true first).

@FireController1847
Copy link
Author

I'm pretty sure https://github.com/fent/node-m3u8stream/pull/7/files#diff-0dd6548c03db28e92002c355f52d343bL50 is causing the memory leak. I'm going to wait to continue on this project until you've commented.

Copy link
Owner

@fent fent left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a few things I'd do differently. I don't think making this feature specific to ytdl is necessary as this can be applied to other similar m3u8 playlists.

I'm not sure I understand the ytdl events for the parser. But the parser, I'd change it so that it would associates segment properties, such as time, to that segment. Compared to storing every line separately.

lib/index.js Outdated
clearTimeout(tid);
fetchingPlaylist = true;
var req = miniget(playlistURL, requestOptions);
req.on('error', onError);
var parser = req.pipe(new m3u8());
if (ytdlOptions && ytft) ytdlOptions.firstTime = true;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better to not manage state by altering objects that were passed from outside the module.

lib/index.js Outdated
@@ -74,6 +79,21 @@ module.exports = function(playlistURL, options) {
totalItems++;
requestQueue.push(item, onQueuedEnd);
});
parser.on('ytdl-end', function() {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you notice the program building up memory? If so, don't think it would be from here, parser is not used or saved outside of this function. The delete operator also cannot delete variables, it only deletes keys from an object. https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/delete

@FireController1847
Copy link
Author

To be honest, this was mostly just to demonstrate what we had to do to get it to actually work, so you could understand what I was talking about (because through text none of that would have made sense lol). I'm not exactly sure how you want to do this specifically.

@fent
Copy link
Owner

fent commented Sep 3, 2017

Yeah, I think I understand now. Thanks for taking the time to put this together and explain.

@FireController1847
Copy link
Author

@fent Yeah no problem! This was definitely a learning process for me haha! :D If you need me to explain anything else about it just ask ;P

@FireController1847
Copy link
Author

waits patiently to see if he forgets about this

@FireController1847
Copy link
Author

FireController1847 commented Nov 2, 2017

So I've decided to take this on again, but this time in a production environment. I have a question, though. I'm not sure what to classify the option as because it's really indescribable. Possibly, "youtubeFormatting" or something similar? I'm just going to use "youtubeFormatting" for my current tests until I get a response :)

Edit: I just started testing with the new system and oh my god is it laggy... So I'm currently unable to execute this as of right now. Every time I got the last item in a request, it would take up to 10 seconds to call the next request and each segment is only 5 seconds long. I don't know if there's a way we can do this. Of course, I'm not an expert in m3u8 so there's probably a way but idk. I'm willing to work with you if you'd like, I'm FireController1847#3577 on Discord.

@FireController1847
Copy link
Author

Okay, so I've made some changes and I've come to this. This is actually working very well as of right now, but there is one issue where it will just cut off (stop working) over a few seconds. It's working extremely well, though, and is much better than my previous commit.

To enable this mode, all you add are two options (using YTDL as the example):

    let req = m3u8stream(url, {
      chunkReadahead: +info.live_chunk_readahead,
      requestOptions: options.requestOptions,
+     refreshInterval: 4000,
+     youtubeFormatting: true
    });

Copy link
Author

@FireController1847 FireController1847 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall, there's just one issue that I'm not sure how to fix and it's the issue where it stops working about 5-10 seconds in. It works fine between each jump initially until that point, in which I'm not sure if the stream is just not working or what. This should be a much easier basis for you to build upon, instead of my (terrible code) of an example I had last time.

lib/index.js Outdated
@@ -61,7 +61,7 @@ module.exports = (playlistURL, options) => {
fetchingPlaylist = true;
var req = miniget(playlistURL, requestOptions);
req.on('error', onError);
var parser = req.pipe(new m3u8());
var parser = req.pipe(new m3u8(options));
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pass the options through, as they're needed for youtubeFormatting detection

super({ decodeStrings: false });
this.options = options;
this._firstLines = [];
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We must save _firstLines so we output the initial tags.

super({ decodeStrings: false });
this.options = options;
this._firstLines = [];
this.first = true;
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is required so we know which lines are _firstLines

} else {
// Save the last line in case it has been broken up.
this._lastLine = line;
if (!this.options.youtubeFormatting) {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we do not want to use youtubeFormatting, use the old way

}
});
} else {
this._lastLine = lines[lines.length - 2];
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Set the last line to before the blank output [' '], which is two back. This is the last audio output.


_final(callback) {
if (this.options.youtubeFormatting) {
const firstIndex = this._firstLines.findIndex(a => a.includes('#EXTINF'));
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Find the first audio mark in _firstLines

_final(callback) {
if (this.options.youtubeFormatting) {
const firstIndex = this._firstLines.findIndex(a => a.includes('#EXTINF'));
for (let i = 0; i < firstIndex.length - 1; i++) {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Loop through everything before the first audio mark so we get all of those juicy details about the stream

}
});
this._parseLine(this._lastLine);
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parse the final audio line

@FireController1847
Copy link
Author

@fent Any particular status on this new commit?

@fent
Copy link
Owner

fent commented Dec 8, 2017

Hi, sorry, I haven't had the time to look at this in detail.

@FireController1847
Copy link
Author

Ah, that's okay, I fully understand.

@FireController1847
Copy link
Author

Um?

@fent
Copy link
Owner

fent commented Jan 15, 2018

Sorry, was changing some things around. But don't worry, I still have this PR/feature in mind

@FireController1847
Copy link
Author

Alright, cool. Just let me know if you need anything more explained!

@FireController1847 FireController1847 deleted the ytdl-fix branch January 30, 2018 04:11
@FireController1847
Copy link
Author

FireController1847 commented Jan 30, 2018

@fent Sorry I'm pestering you about this, but it's been ~4 months since I made this PR with no significant changes. I'd love to be able to help out, but it's not really clear what is needed to do so (considering this was closed). This is a bug that many Discord.js users are hoping will get fixed, as it causes unstability in their programs. If there's anything you need me to do, please tell me, as I want to get this fixed as fast as possible. (And yes, I know it's only been 14 days since my last message but... yeah, I have no excuse)

@fent
Copy link
Owner

fent commented Jan 30, 2018

it's been ~4 months since I made this PR with no significant changes

Sorry for the big delay. It's cool to feel the need to alert me. I'll try to prioritize this when I next dive into my open source stuff.

@FireController1847
Copy link
Author

Alright cool. Thanks 👍

@FireController1847
Copy link
Author

@fent This is your 3-monthly reminder to not forget about this PR! :P

@FireController1847
Copy link
Author

@fent This is your do-it-when-a-reasonable-amount-of-time-has-passed reminder to not forget about this PR

@fent
Copy link
Owner

fent commented Apr 20, 2018

thesadfrog

@nikbrandt
Copy link

@fent ily
but it's almost the one year anniversary of this issue (56 more days 'till then lol) and i thought id give the reminder this time 'cause ive had this one card in my trello waiting around for a long time lmao

pls ;-;

we should throw a party at the anniversary 🤔

@FireController1847
Copy link
Author

Holy fek, it is almost a year old.

@fent
Copy link
Owner

fent commented Jun 6, 2018

Sorry for the big delay. One of my goals as a dev is to not neglect any of my active repos. Which I've failed to do here :(

I'll try to look at this at the beginning of next week.

@FireController1847
Copy link
Author

Awesome, can't wait. Glad to hear you haven't entirely forgotten about it :)

@FireController1847 FireController1847 restored the ytdl-fix branch June 6, 2018 06:29
fent added a commit that referenced this pull request Jul 19, 2018
This was specifically aimed towards youtube live streams, which contain
segments for the past 4 hours streamed. An additional option was added,
`liveBuffer` to work with `begin`.

The `refreshInterval` option was removed, the playlist will now only
refresh as the last segment in the playlist is approached. This works
better for streams played at a variable rate.

closes #7
@FireController1847
Copy link
Author

Woohoo! Glad we finally got it done @fent. Good on ya' 🎉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants