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 # 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 # does not save the file, only the most recent fragment is held. Thus, this is
# ideal for devices with little memory # ideal for devices with little memory
# TODO gather video metadata before download
# #
import ffmpeg
import tempfile import tempfile
import sys import sys
import subprocess import subprocess
import os import os
import pip import pip
import signal import signal
import json
from logger import logger from logger import logger
from threading import Thread, main_thread from threading import Thread, main_thread
from time import sleep from time import sleep
@ -26,39 +27,71 @@ dirpath = tempfile.mkdtemp()
sys.path.append(dirpath) sys.path.append(dirpath)
updateYtdlp() 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): def __init__(self, url, cb):
Thread.__init__(self) Thread.__init__(self)
# update yt-dlp
# TODO: do this only every once in a while
# updateYtdlp()
self.cb = cb self.cb = cb
self.exit = False self.exit = False
env = dict(os.environ) self.popen = runYtdlp([
env["PYTHONPATH"] = dirpath
cmd = [
sys.executable,
dirpath + "/bin/yt-dlp",
"-o", "-", # output stream to stdout "-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 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() self.start()
def isAlive(self): def isAlive(self):
@ -80,3 +113,39 @@ class Downloader(Thread, StreamSource):
def getStream(self): 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 # plays the next song in the queue
def play(self): def play(self):
self.playingUrl = self.queue.get() 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.transcoder = transcoder.Transcoder(self.downloader)
self.buffer = buffer.Buffer(self.transcoder) self.buffer = buffer.Buffer(self.transcoder)
self.uploader.setUpstream(self.buffer) self.uploader.setUpstream(self.buffer)

View File

@ -4,36 +4,29 @@ from stream_listener import StreamListener
from stream import StreamSource from stream import StreamSource
import os import os
fifoFile = 'transcode-fifo'
# converts the stream to mp3 before sending to the long-lasting mp3 connection # 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 # 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 # https://lists.libav.org/pipermail/ffmpeg-user/2007-May/008917.html
class Transcoder(StreamSource): class Transcoder(StreamSource):
def __init__(self, upstream): def __init__(self, upstream):
if os.path.exists(fifoFile):
os.remove(fifoFile)
os.mkfifo(fifoFile)
self.process = ( ffmpeg self.process = ( ffmpeg
.input(fifoFile) .input('pipe:')
.output('pipe:', format='mp3') .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") logger.add(self.process.stderr, "transcoder.log")
self.listener = StreamListener(upstream, self) self.listener = StreamListener(upstream, self)
self.listener.start() self.listener.start()
self.file = open(fifoFile, 'wb')
def stop(self): def stop(self):
self.file.close()
self.listener.stop() self.listener.stop()
self.process.stdin.close()
self.process.stdout.close() self.process.stdout.close()
self.process.stderr.close() self.process.stderr.close()
os.remove(fifoFile)
def getStream(self): def getStream(self):
return self.process.stdout return self.process.stdout
def write(self, chunk): def write(self, chunk):
self.file.write(chunk) self.process.stdin.write(chunk)