Improved player to support sidecar external subtitles
This commit is contained in:
parent
fe803d66af
commit
5ad3eb2527
8 changed files with 307 additions and 56 deletions
|
|
@ -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.
|
||||
|
|
|
|||
75
app/main.py
75
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:
|
||||
|
|
|
|||
|
|
@ -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
77
app/player/Playlist.py
Normal 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
66
app/player/Subtitle.py
Normal 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
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue