Improved player to support sidecar external subtitles

This commit is contained in:
ArabCoders 2024-07-31 19:34:14 +03:00
parent fe803d66af
commit 5ad3eb2527
8 changed files with 307 additions and 56 deletions

View file

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

View file

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

View file

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

77
app/player/Playlist.py Normal file
View file

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

66
app/player/Subtitle.py Normal file
View file

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

View file

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

View file

@ -1,3 +1,21 @@
<style>
:root {
--plyr-captions-background: rgba(0, 0, 0, 0.6);
--plyr-captions-text-color: #f3db4d;
--webkit-text-track-display: none;
}
.plyr__caption {
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.7);
font-size: 140%;
font-weight: bold;
}
.plyr--full-ui ::-webkit-media-text-track-container {
display: var(--webkit-text-track-display);
}
</style>
<template>
<video ref="video" :poster="previewImageLink" :controls="isControls" :title="title">
<source :src="link" type="application/x-mpegURL" />
@ -32,35 +50,43 @@ const props = defineProps({
const emitter = defineEmits(['closeModel'])
const video = ref(null)
const player = ref(null)
const hls = ref(null)
let eventHandler = null
let player = null;
let hls = null;
const eventFunc = e => {
if (e.key === 'Escape') {
emitter('closeModel')
}
}
onMounted(() => {
if (/(iPhone|iPod|iPad).*AppleWebKit/i.test(navigator.userAgent)) {
document.documentElement.style.setProperty('--webkit-text-track-display', 'block');
}
prepareVideoPlayer()
eventHandler = window.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
emitter('closeModel')
}
})
window.addEventListener('keydown', eventFunc)
})
onUpdated(() => {
prepareVideoPlayer()
})
onUpdated(() => prepareVideoPlayer())
onUnmounted(() => {
player.value.destroy()
hls.value.destroy()
window.removeEventListener('keydown', eventHandler)
if (player) {
player.destroy()
}
if (hls) {
hls.destroy()
}
window.removeEventListener('keydown', eventFunc)
})
const prepareVideoPlayer = () => {
player.value = new Plyr(video.value, {
player = new Plyr(video.value, {
debug: false,
clickToPlay: true,
keyboard: { focused: true, global: true },
controls: [
'play-large', 'play', 'progress', 'current-time', 'duration', 'mute', 'volume', 'pip', 'airplay', 'fullscreen'
'play-large', 'play', 'progress', 'current-time', 'duration', 'mute', 'volume', 'captions', 'settings', 'pip', 'airplay', 'fullscreen'
],
fullscreen: {
enabled: true,
@ -69,14 +95,17 @@ const prepareVideoPlayer = () => {
},
storage: {
enabled: true,
key: 'plyr_volume'
key: 'plyr'
},
mediaMetadata: {
title: props.title.value
title: props.title
},
captions: {
update: true,
}
});
hls.value = new Hls({
hls = new Hls({
debug: false,
enableWorker: true,
lowLatencyMode: true,
@ -84,13 +113,10 @@ const prepareVideoPlayer = () => {
fragLoadingTimeOut: 200000,
});
hls.value.loadSource(props.link)
hls.loadSource(props.link)
if (video.value) {
hls.value.attachMedia(video.value)
hls.attachMedia(video.value)
}
}
</script>
<style></style>

View file

@ -30,15 +30,17 @@ const app = createApp(App);
app.config.globalProperties.capitalize = s => s && s[0].toUpperCase() + s.slice(1);
app.config.globalProperties.makeDownload = (config, item, base = 'download') => {
let baseDir = `${base}/`;
let baseDir = 'download' === base ? `${base}/` : 'player/playlist/';
if (item.folder) {
item.folder = item.folder.replace('#', '%23');
baseDir += item.folder + '/';
}
return config.app.url_host + config.app.url_prefix + baseDir + encodeURIComponent(item.filename);
let url = config.app.url_host + config.app.url_prefix + baseDir + encodeURIComponent(item.filename);
return ('m3u8' === base) ? url + '.m3u8' : url;
}
app.config.globalProperties.formatBytes = (bytes, decimals = 2) => {
if (!+bytes) return '0 Bytes'