support direct mp4 files

This commit is contained in:
zuckerberg 2021-09-08 19:32:46 -04:00
parent 5de2148123
commit 5a5b0afaaa
3 changed files with 106 additions and 38 deletions

View File

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

View File

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

View File

@ -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)
self.process.stdin.write(chunk)