diff --git a/README.md b/README.md index de94a170..f16e6734 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Web GUI for [yt-dlp](https://github.com/yt-dlp/yt-dlp) with playlist & channel s YTPTube started as a fork of [meTube](https://github.com/alexta69/metube), Since then it went under heavy changes, and it supports many new features. # YTPTube Features. -* A built in video player that can play any video file regardless of the format. +* A built in video player that can play any video file regardless of the format. **With support for sidecar external subtitles**. * New `/add_batch` endpoint that allow multiple links to be sent. * Completely redesigned the frontend UI. * Switched out of binary file storage in favor of SQLite. @@ -17,6 +17,7 @@ YTPTube started as a fork of [meTube](https://github.com/alexta69/metube), Since * Webhook sender. It allow you to add webhook endpoints that receive events related to downloads using simple `json` file. * Multi-downloads support. * Basic Authentication support. +* Support for curl_cffi, see [yt-dlp documentation](https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#impersonation) ### Tips Your `yt-dlp` config should include the following options for optimal working conditions. diff --git a/app/main.py b/app/main.py index 5b5fe9b6..5e0b9a1a 100644 --- a/app/main.py +++ b/app/main.py @@ -13,8 +13,10 @@ from Utils import ObjectSerializer, Notifier, isDownloaded, load_file from aiohttp import web, client from aiohttp.web import Request, Response, RequestHandler from Webhooks import Webhooks +from player.Playlist import Playlist from player.M3u8 import M3u8 from player.Segments import Segments +from player.Subtitle import Subtitle import socketio import logging import caribou @@ -451,21 +453,56 @@ class Main: return web.json_response({"status": "stopped" if status else "in_error_state"}) - @self.routes.get(self.config.url_prefix + 'm3u8/{file:.*}') - async def m3u8(request: Request) -> Response: + @self.routes.get(self.config.url_prefix + 'player/playlist/{file:.*}.m3u8') + async def playlist(request: Request) -> Response: file: str = request.match_info.get('file') if not file: raise web.HTTPBadRequest(reason='file is required.') try: - text = await M3u8(url=f"{self.config.url_host}{self.config.url_prefix}").make_stream( + text = await Playlist(url=f"{self.config.url_host}{self.config.url_prefix}").make( download_path=self.config.download_path, file=file ) except Exception as e: return web.HTTPNotFound(reason=str(e)) + return web.Response(text=text, headers={ + 'Content-Type': 'application/x-mpegURL', + 'Cache-Control': 'no-cache', + 'Access-Control-Max-Age': "300", + }) + + @self.routes.get(self.config.url_prefix + 'player/m3u8/{mode}/{file:.*}.m3u8') + async def m3u8(request: Request) -> Response: + file: str = request.match_info.get('file') + mode: str = request.match_info.get('mode') + + if mode not in ['video', 'subtitle']: + raise web.HTTPBadRequest(reason='Only video and subtitle modes are supported.') + + if not file: + raise web.HTTPBadRequest(reason='file is required.') + + duration = request.query.get('duration', None) + + if 'subtitle' in mode: + if not duration: + raise web.HTTPBadRequest(reason='duration is required.') + + duration = float(duration) + + try: + cls = M3u8(f"{self.config.url_host}{self.config.url_prefix}") + if 'subtitle' in mode: + text = await cls.make_subtitle(self.config.download_path, file, duration) + else: + text = await cls.make_stream(self.config.download_path, file) + + except Exception as e: + return web.HTTPNotFound(reason=str(e)) + return web.Response( text=text, headers={ @@ -475,7 +512,7 @@ class Main: } ) - @self.routes.get(self.config.url_prefix + 'segments/{segment:\d+}/{file:.*}') + @self.routes.get(self.config.url_prefix + 'player/segments/{segment:\d+}/{file:.*}.ts') async def segments(request: Request) -> Response: file: str = request.match_info.get('file') segment: int = request.match_info.get('segment') @@ -516,6 +553,36 @@ class Main: } ) + @self.routes.get(self.config.url_prefix + 'player/subtitle/{file:.*}.vtt') + async def subtitles(request: Request) -> Response: + file: str = request.match_info.get('file') + + if request.if_modified_since: + file_path = os.path.join(self.config.download_path, file) + lastMod = time.strftime('%a, %d %b %Y %H:%M:%S GMT', datetime.fromtimestamp( + os.path.getmtime(os.path.join(self.config.download_path, file))).timetuple() + ) + if os.path.exists(file_path) and request.if_modified_since.timestamp() == os.path.getmtime(file_path): + return web.Response(status=304, headers={'Last-Modified': lastMod}) + + if not file: + raise web.HTTPBadRequest(reason='file is required') + + return web.Response( + body=await Subtitle().make(path=self.config.download_path, file=file), + headers={ + 'Content-Type': 'text/vtt; charset=UTF-8', + 'X-Accel-Buffering': 'no', + 'Access-Control-Allow-Origin': '*', + 'Pragma': 'public', + 'Cache-Control': f'public, max-age={time.time() + 31536000}', + 'Last-Modified': time.strftime('%a, %d %b %Y %H:%M:%S GMT', datetime.fromtimestamp( + os.path.getmtime(os.path.join(self.config.download_path, file))).timetuple() + ), + 'Expires': time.strftime('%a, %d %b %Y %H:%M:%S GMT', datetime.fromtimestamp(time.time() + 31536000).timetuple()), + } + ) + @self.routes.get(self.config.url_prefix) async def index(_) -> Response: if not self.appLoader: diff --git a/app/player/M3u8.py b/app/player/M3u8.py index 22790217..4681b5d6 100644 --- a/app/player/M3u8.py +++ b/app/player/M3u8.py @@ -33,11 +33,13 @@ class M3u8: duration: float = float(ffprobe.metadata.get('duration')) - m3u8 = "#EXTM3U\n" - m3u8 += "#EXT-X-VERSION:3\n" - m3u8 += f"#EXT-X-TARGETDURATION:{int(self.duration)}\n" - m3u8 += "#EXT-X-MEDIA-SEQUENCE:0\n" - m3u8 += "#EXT-X-PLAYLIST-TYPE:VOD\n" + m3u8 = [] + + m3u8.append("#EXTM3U") + m3u8.append("#EXT-X-VERSION:3") + m3u8.append(f"#EXT-X-TARGETDURATION:{int(self.duration)}") + m3u8.append("#EXT-X-MEDIA-SEQUENCE:0") + m3u8.append("#EXT-X-PLAYLIST-TYPE:VOD") segmentSize: float = '{:.6f}'.format(self.duration) splits: int = math.ceil(duration / self.duration) @@ -54,26 +56,36 @@ class M3u8: for i in range(splits): if (i + 1) == splits: - segmentParams.update({'sd': '{:.6f}'.format(duration - (i * self.duration))}) - m3u8 += f"#EXTINF:{segmentParams['sd']}, nodesc\n" - else: - m3u8 += f"#EXTINF:{segmentSize}, nodesc\n" + segmentSize = '{:.6f}'.format(duration - (i * self.duration)) + segmentParams.update({'sd': segmentSize}) - m3u8 += f"{self.url}segments/{i}/{quote(file)}" + m3u8.append(f"#EXTINF:{segmentSize},") + + url = f"{self.url}player/segments/{i}/{quote(file)}.ts" if len(segmentParams) > 0: - m3u8 += '?'+'&'.join([f"{key}={value}" for key, value in segmentParams.items()]) - m3u8 += "\n" + url += '?'+'&'.join([f"{key}={value}" for key, value in segmentParams.items()]) - m3u8 += "#EXT-X-ENDLIST\n" + m3u8.append(url) - return m3u8 + m3u8.append("#EXT-X-ENDLIST") - def parseDuration(self, duration: str): - if duration.find(':') > -1: - duration = duration.split(':') - duration.reverse() - duration = sum([float(duration[i]) * (60 ** i) for i in range(len(duration))]) - else: - duration = float(duration) + return '\n'.join(m3u8) - return duration + async def make_subtitle(self, download_path: str, file: str, duration: float): + realFile: str = calcDownloadPath(basePath=download_path, folder=file, createPath=False) + + if not os.path.exists(realFile): + raise Exception(f"File '{realFile}' does not exist.") + + m3u8 = [] + + m3u8.append("#EXTM3U") + m3u8.append("#EXT-X-VERSION:3") + m3u8.append(f"#EXT-X-TARGETDURATION:{int(self.duration)}") + m3u8.append("#EXT-X-MEDIA-SEQUENCE:0") + m3u8.append("#EXT-X-PLAYLIST-TYPE:VOD") + m3u8.append(f"#EXTINF:{duration},") + m3u8.append(f"{self.url}player/subtitle/{quote(file)}.vtt") + m3u8.append("#EXT-X-ENDLIST") + + return '\n'.join(m3u8) diff --git a/app/player/Playlist.py b/app/player/Playlist.py new file mode 100644 index 00000000..254fa292 --- /dev/null +++ b/app/player/Playlist.py @@ -0,0 +1,77 @@ +import glob +import pathlib +import re +from urllib.parse import quote +from Utils import calcDownloadPath +import pathlib +from .ffprobe import FFProbe +from .Subtitle import Subtitle + + +class Playlist: + _url: str = None + + def __init__(self, url: str): + self.url = url + + async def make(self, download_path: str, file: str): + rFile = pathlib.Path(calcDownloadPath(basePath=download_path, folder=file, createPath=False)) + + if not rFile.exists(): + raise Exception(f"File '{rFile}' does not exist.") + + try: + ffprobe = FFProbe(rFile) + await ffprobe.run() + except UnicodeDecodeError as e: + pass + + if not 'duration' in ffprobe.metadata: + raise Exception(f"Unable to get '{rFile}' duration.") + + duration: float = float(ffprobe.metadata.get('duration')) + + playlist = [] + playlist.append("#EXTM3U") + + subs = "" + + index = 0 + for item in self.getSideCarFiles(rFile): + if not item.suffix in Subtitle.allowedExtensions: + continue + + index += 1 + lang: str = "und" + lg = re.search(r'\.(?P\w{2,3})\.\w{3}$', item.name) + if lg: + lang = lg.groupdict().get('lang') + + subs = ',SUBTITLES="subs"' + + url = f"{self.url}player/m3u8/subtitle/{quote(str(pathlib.Path(file).with_name(item.name)))}.m3u8?duration={duration}" + name = f"{item.suffix[1:].upper()} ({index}) - {lang}" + playlist.append( + f'#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="{name}",DEFAULT=NO,AUTOSELECT=NO,FORCED=NO,LANGUAGE="{lang}",URI="{url}"') + + playlist.append(f'#EXT-X-STREAM-INF:PROGRAM-ID=1{subs}') + playlist.append(f"{self.url}player/m3u8/video/{quote(file)}.m3u8") + + return '\n'.join(playlist) + + def getSideCarFiles(self, file: pathlib.Path) -> list[pathlib.Path]: + """ + Get sidecar files for the given file. + + :param file: File to get sidecar files for. + :return: List of sidecar files. + """ + files = [] + + for sub_file in file.parent.glob(f"{glob.escape(file.stem)}.*"): + if sub_file == file or sub_file.is_file() is False or sub_file.stem.startswith('.'): + continue + + files.append(sub_file) + + return files diff --git a/app/player/Subtitle.py b/app/player/Subtitle.py new file mode 100644 index 00000000..8e602617 --- /dev/null +++ b/app/player/Subtitle.py @@ -0,0 +1,66 @@ +import asyncio +import hashlib +import logging +import os +import tempfile +from Utils import calcDownloadPath +import pathlib + +LOG = logging.getLogger('player.subtitle') + + +class Subtitle: + allowedExtensions: tuple[str] = (".srt", ".vtt", ".ass",) + + async def make(self, path: str, file: str) -> bytes: + realFile: str = calcDownloadPath(basePath=path, folder=file, createPath=False) + + rFile = pathlib.Path(realFile) + + if not rFile.exists(): + raise Exception(f"File '{file}' does not exist.") + + if not rFile.suffix in self.allowedExtensions: + raise Exception(f"File '{file}' subtitle type is not supported.") + + if rFile.suffix is ".vtt": + subData = '' + with open(realFile, 'r') as f: + subData = f.read() + + return subData + + tmpDir: str = tempfile.gettempdir() + tmpFile = os.path.join(tmpDir, f'player.subtitle.{hashlib.md5(realFile.encode("utf-8")).hexdigest()}') + + if not os.path.exists(tmpFile): + os.symlink(realFile, tmpFile) + + fargs = [] + fargs.append('-xerror') + fargs.append('-hide_banner') + fargs.append('-loglevel') + fargs.append('error') + fargs.append('-i') + fargs.append(f'file:{tmpFile}') + fargs.append('-f') + fargs.append('webvtt') + fargs.append('pipe:1') + + LOG.debug(f"Converting '{realFile}' into 'webvtt'. " + " ".join(fargs)) + + proc = await asyncio.subprocess.create_subprocess_exec( + 'ffmpeg', *fargs, + stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + data, err = await proc.communicate() + + if 0 != proc.returncode: + msg = f"Failed to convert '{realFile}' into 'webvtt'." + LOG.error(f'{msg} {err.decode("utf-8")}.') + raise Exception(msg) + + return data diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 8f06aede..c3ad5d8d 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -157,7 +157,7 @@ onMounted(() => { }); const archiveItem = (type, item) => { - if (!confirm(`Archive '${item.title ?? item.id ?? item.url ?? '??'}'?`)){ + if (!confirm(`Archive '${item.title ?? item.id ?? item.url ?? '??'}'?`)) { return } sendData('archive_item', item); @@ -230,14 +230,14 @@ const addItem = (item) => { }; const playItem = item => { - let baseDir = 'm3u8/'; + let baseDir = 'player/playlist/'; if (item.folder) { item.folder = item.folder.replace('#', '%23'); baseDir += item.folder + '/'; } - video_link.value = config.app.url_host + config.app.url_prefix + baseDir + encodeURIComponent(item.filename); + video_link.value = config.app.url_host + config.app.url_prefix + baseDir + encodeURIComponent(item.filename) + '.m3u8'; }; const reloadWindow = () => window.location.reload(); diff --git a/frontend/src/components/Video-Player.vue b/frontend/src/components/Video-Player.vue index 3ce961ef..6cb5b437 100644 --- a/frontend/src/components/Video-Player.vue +++ b/frontend/src/components/Video-Player.vue @@ -1,3 +1,21 @@ + +