diff --git a/downloader.py b/downloader.py index b846359..7dab0bc 100644 --- a/downloader.py +++ b/downloader.py @@ -2,15 +2,16 @@ # Downloads the video/audio as a stream from a provided link using yt-dlp # does not save the file, only the most recent fragment is held. Thus, this is # ideal for devices with little memory -# TODO gather video metadata before download # +import ffmpeg import tempfile import sys import subprocess import os import pip import signal +import json from logger import logger from threading import Thread, main_thread from time import sleep @@ -26,39 +27,71 @@ dirpath = tempfile.mkdtemp() sys.path.append(dirpath) updateYtdlp() -class Downloader(Thread, StreamSource): +baseOpts = [ + "-f", "bestaudio/best", # select for best audio + # "--audio-format", "mp3", "-x", # cannot do because it requires a tmp file to re-encode + "--prefer-ffmpeg", + "--no-mark-watched", + "--geo-bypass", + "--no-playlist", + "--retries", "100", + "--extractor-retries", "100", +# "--throttled-rate", "100K", # get around youtube throttling; probably not needed anymore + "--no-call-home", + "--sponsorblock-remove", "sponsor,intro,selfpromo,interaction,preview,music_offtopic", +] +def runYtdlp(opts): + # update yt-dlp + # TODO: do this only every once in a while + # updateYtdlp() + + env = dict(os.environ) + env["PYTHONPATH"] = dirpath + cmd = [ + sys.executable, + dirpath + "/bin/yt-dlp" + ] + baseOpts + opts + popen = subprocess.Popen(cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, preexec_fn=os.setsid) + logger.add(popen.stderr, "yt-dlp.log") + return popen + +def getVideoInfo(url): + popen = runYtdlp([ + "-j", # dump all metadata as json + url + ]) + + j = popen.communicate()[0] + + # make sure the ytdlp instance is closed + if popen.poll() is None: # is alive? + os.killpg(os.getpgid(popen.pid), signal.SIGTERM) # kill + popen.stdout.close() + + # keys of interest (but not always present) + # title, direct, extractor, upload_date + # format_id (machine readable) + # view_count, uploader, duration, view_count, average_rating + # age_limit, like_count, dislike_count, thumbnail, + + try: + return json.loads(j) + except: + return None + +# Downloads using yt-dlp +class YtdlpDownloader(Thread, StreamSource): def __init__(self, url, cb): Thread.__init__(self) - # update yt-dlp - # TODO: do this only every once in a while - # updateYtdlp() - self.cb = cb self.exit = False - env = dict(os.environ) - env["PYTHONPATH"] = dirpath - cmd = [ - sys.executable, - dirpath + "/bin/yt-dlp", + self.popen = runYtdlp([ "-o", "-", # output stream to stdout - "-f", "bestaudio/best", # select for best audio - # "--audio-format", "mp3", "-x", # cannot do because it requires a tmp file to re-encode - "--prefer-ffmpeg", - "--no-mark-watched", - "--geo-bypass", - "--no-playlist", - "--retries", "100", - "--extractor-retries", "100", -# "--throttled-rate", "100K", # get around youtube throttling; probably not needed anymore - "--no-call-home", - "--sponsorblock-remove", "sponsor,intro,selfpromo,interaction,preview,music_offtopic", url - ] - self.popen = subprocess.Popen(cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, preexec_fn=os.setsid) - logger.add(self.popen.stderr, "yt-dlp.log") + ]) self.start() def isAlive(self): @@ -79,4 +112,40 @@ class Downloader(Thread, StreamSource): sleep(0.1) def getStream(self): - return self.popen.stdout \ No newline at end of file + return self.popen.stdout + +# Downloads the file and plays it (must be a direct URL, ex: cannot be a yt link) +class DirectDownloader(Thread, StreamSource): + def __init__(self, url, cb): + Thread.__init__(self) + + self.cb = cb + self.exit = False + + self.process = ( ffmpeg + .input(url) + .output('pipe:', format='mp3') + .run_async(pipe_stdout=True, pipe_stderr=True) + ) + logger.add(self.process.stderr, "direct-downloader.log") + + self.start() + + def isAlive(self): + return self.process.poll() is None + + # checks to see if the current download has finished + # if yes, cleans up and fires callback + def run(self): + while main_thread().is_alive() and not self.exit: + if not self.isAlive(): + self.cb() + sleep(0.1) + + def stop(self): + self.process.stdout.close() + self.process.stderr.close() + self.exit = True + + def getStream(self): + return self.process.stdout \ No newline at end of file diff --git a/radio.py b/radio.py index 4d24c91..04c1332 100644 --- a/radio.py +++ b/radio.py @@ -26,7 +26,13 @@ class Radio(object): # plays the next song in the queue def play(self): self.playingUrl = self.queue.get() - self.downloader = downloader.Downloader(self.playingUrl, self.downloadFinished) + info = downloader.getVideoInfo(self.playingUrl) + if info is None: + return self.play() + elif "direct" in info and info["direct"] == True: + self.downloader = downloader.DirectDownloader(self.playingUrl, self.downloadFinished) + else: + self.downloader = downloader.YtdlpDownloader(self.playingUrl, self.downloadFinished) self.transcoder = transcoder.Transcoder(self.downloader) self.buffer = buffer.Buffer(self.transcoder) self.uploader.setUpstream(self.buffer) diff --git a/transcoder.py b/transcoder.py index 391d274..21becaf 100644 --- a/transcoder.py +++ b/transcoder.py @@ -4,36 +4,29 @@ from stream_listener import StreamListener from stream import StreamSource import os -fifoFile = 'transcode-fifo' - # converts the stream to mp3 before sending to the long-lasting mp3 connection # write to a fifo file instead of directly via stdin to fool ffmpeg into thinking it has seekable input # https://lists.libav.org/pipermail/ffmpeg-user/2007-May/008917.html class Transcoder(StreamSource): def __init__(self, upstream): - if os.path.exists(fifoFile): - os.remove(fifoFile) - os.mkfifo(fifoFile) self.process = ( ffmpeg - .input(fifoFile) + .input('pipe:') .output('pipe:', format='mp3') - .run_async(pipe_stdout=True, pipe_stderr=True) + .run_async(pipe_stdin=True, pipe_stdout=True, pipe_stderr=True) ) logger.add(self.process.stderr, "transcoder.log") self.listener = StreamListener(upstream, self) self.listener.start() - self.file = open(fifoFile, 'wb') def stop(self): - self.file.close() self.listener.stop() + self.process.stdin.close() self.process.stdout.close() self.process.stderr.close() - os.remove(fifoFile) def getStream(self): return self.process.stdout def write(self, chunk): - self.file.write(chunk) \ No newline at end of file + self.process.stdin.write(chunk) \ No newline at end of file