try to direct play video first and fallback to hls if we encounter error.

This commit is contained in:
ArabCoders 2025-02-27 18:31:17 +03:00
parent 259eae251a
commit b7b72cf21e
8 changed files with 186 additions and 121 deletions

3
.vscode/launch.json vendored
View file

@ -21,7 +21,8 @@
"NUXT_API_URL": "http://localhost:8081/api/",
"NUXT_PUBLIC_WSS": ":8081/",
},
"console": "internalConsole"
"console": "internalConsole",
"outputCapture": "std",
},
{
"name": "Python: main.py",

View file

@ -38,6 +38,7 @@ from .Utils import (
StreamingError,
arg_converter,
calc_download_path,
get_sidecar_subtitles,
get_video_info,
validate_url,
validate_uuid,
@ -1178,6 +1179,46 @@ class HttpAPI(Common):
except Exception as e:
return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code)
@route("GET", "api/file/info/{file:.*}")
async def get_file_info(self, request: Request) -> Response:
"""
Get file info
Args:
request (Request): The request object.
Returns:
Response: The response object.
"""
file: str = request.match_info.get("file")
if not file:
return web.json_response(data={"error": "file is required."}, status=web.HTTPBadRequest.status_code)
try:
realFile: str = calc_download_path(base_path=self.config.download_path, folder=file, create_path=False)
if not os.path.exists(realFile) or not os.path.isfile(realFile):
return web.json_response(
data={"error": f"File '{file}' does not exist."}, status=web.HTTPNotFound.status_code
)
realFile = Path(realFile)
response = {
"ffprobe": await ffprobe(realFile),
"sidecar": get_sidecar_subtitles(realFile),
}
for i, f in enumerate(response["sidecar"]):
response["sidecar"][i]["file"] = str(Path(realFile).with_name(f["file"].name)).replace(
self.config.download_path, ""
).strip("/")
return web.json_response(data=response, status=web.HTTPOk.status_code, dumps=self.encoder.encode)
except Exception as e:
LOG.exception(e)
return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code)
@route("GET", "api/youtube/auth")
async def is_authenticated(self, request: Request) -> Response:
"""

View file

@ -1,13 +1,10 @@
import glob
import re
from pathlib import Path
from urllib.parse import quote
from aiohttp.web import HTTPFound, Response
from .ffprobe import ffprobe
from .Subtitle import Subtitle
from .Utils import StreamingError, calc_download_path, check_id
from .Utils import StreamingError, calc_download_path, check_id, get_sidecar_subtitles
class Playlist:
@ -41,28 +38,19 @@ class Playlist:
msg = f"Unable to get '{rFile}' duration."
raise StreamingError(msg)
duration: float = float(ff.metadata.get("duration"))
playlist = []
playlist.append("#EXTM3U")
subs = ""
index = 0
for item in self.get_sidecar_files(rFile):
if item.suffix not in Subtitle.allowed_extensions:
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")
duration: float = float(ff.metadata.get("duration"))
for sub_file in get_sidecar_subtitles(rFile):
lang = sub_file["lang"]
item = sub_file["file"]
name = sub_file["name"]
subs = ',SUBTITLES="subs"'
url = f"{self.url}api/player/m3u8/subtitle/{quote(str(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}"'
)
@ -71,20 +59,3 @@ class Playlist:
playlist.append(f"{self.url}api/player/m3u8/video/{quote(file)}.m3u8")
return "\n".join(playlist)
def get_sidecar_files(self, file: Path) -> list[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

View file

@ -1,4 +1,5 @@
import copy
import glob
import ipaddress
import json
import logging
@ -27,6 +28,8 @@ IGNORED_KEYS: tuple[str] = (
)
YTDLP_INFO_CLS: yt_dlp.YoutubeDL = None
ALLOWED_SUBS_EXTENSIONS: tuple[str] = (".srt", ".vtt", ".ass")
class StreamingError(Exception):
"""Raised when an error occurs during streaming."""
@ -524,3 +527,27 @@ def validate_uuid(uuid_str: str, version: int = 4) -> bool:
return True
except ValueError:
return False
def get_sidecar_subtitles(file: pathlib.Path) -> list[dict]:
"""
Get sidecar files for the given file.
:param file: File to get sidecar files for.
:return: List of sidecar files.
"""
files = []
for i, f in enumerate(file.parent.glob(f"{glob.escape(file.stem)}.*")):
if f == file or f.is_file() is False or f.stem.startswith("."):
continue
if f.suffix not in ALLOWED_SUBS_EXTENSIONS:
continue
lg = re.search(r"\.(?P<lang>\w{2,3})\.\w{3}$", f.name)
lang = lg.groupdict().get("lang") if lg else "und"
files.append({"file": f, "lang": lang, "name": f"{f.suffix[1:].upper()} ({i}) - {lang}"})
return files

View file

@ -1,4 +1,5 @@
import json
from pathlib import Path
class Encoder(json.JSONEncoder):
@ -9,10 +10,14 @@ class Encoder(json.JSONEncoder):
"""
def default(self, o):
if isinstance(o, object) and hasattr(o, "serialize"):
return o.serialize()
if isinstance(o, Path):
return str(o)
if isinstance(o, object) and hasattr(o, "__dict__"):
return o.__dict__
if isinstance(o, object):
if hasattr(o, "serialize"):
return o.serialize()
if hasattr(o, "__dict__"):
return o.__dict__
return json.JSONEncoder.default(self, o)

View file

@ -235,12 +235,11 @@
</p>
</div>
<div class="modal is-active" v-if="video_link">
<div class="modal is-active" v-if="video_item">
<div class="modal-background" @click="closeVideo"></div>
<div class="modal-content">
<VideoPlayer type="default" :link="video_link" :isMuted="false" autoplay="true" :isControls="true"
:title="video_title" :thumbnail="video_thumbnail" :artist="video_artist" class="is-fullwidth"
@closeModel="closeVideo" />
<VideoPlayer type="default" :isMuted="false" autoplay="true" :isControls="true" :item="video_item"
class="is-fullwidth" @closeModel="closeVideo" />
</div>
<button class="modal-close is-large" aria-label="close" @click="closeVideo"></button>
</div>
@ -264,36 +263,10 @@ const showCompleted = useStorage('showCompleted', true)
const hideThumbnail = useStorage('hideThumbnailHistory', false)
const direction = useStorage('sortCompleted', 'desc')
const video_link = ref('')
const video_title = ref('')
const video_thumbnail = ref('')
const video_artist = ref('')
const video_item = ref(null)
const playVideo = item => {
video_thumbnail.value = '';
video_artist.value = '';
video_link.value = makeDownload(config, item, 'm3u8')
video_title.value = item.title
if (item.extras?.thumbnail) {
video_thumbnail.value = '/api/thumbnail?url=' + encodePath(item.extras.thumbnail)
}
if (item.extras?.channel) {
video_artist.value = item.extras.channel
}
if (!video_artist.value && item.extras?.uploader) {
video_artist.value = item.extras.uploader
}
if (!item.extras?.is_video && item.extras?.is_audio) {
video_audio.value = true
}
}
const closeVideo = () => {
video_link.value = ''
video_title.value = ''
video_thumbnail.value = ''
video_artist.value = ''
}
const playVideo = item => video_item.value = item
const closeVideo = () => video_item.value = null
watch(masterSelectAll, (value) => {
for (const key in stateStore.history) {

View file

@ -18,8 +18,10 @@
<template>
<div>
<video ref="video" :poster="thumbnail" :controls="isControls" :title="title" playsinline>
<source :src="link" type="application/x-mpegURL" />
<video id="player" ref="video" :poster="thumbnail" :title="title" playsinline>
<source v-for="source in sources" :key="source.src" :src="source.src" @error="source.onerror" />
<track v-for="track in tracks" :key="track.file" :kind="track.kind" :label="track.label" :srclang="track.lang"
:src="track.file" default />
</video>
</div>
</template>
@ -29,33 +31,27 @@ import { onMounted, onUpdated, ref, defineProps, defineEmits, onUnmounted } from
import Hls from 'hls.js'
import Plyr from 'plyr'
import 'plyr/dist/plyr.css'
import { makeDownload } from '~/utils/index'
const config = useConfigStore()
const props = defineProps({
link: {
type: String,
default: ''
},
title: {
type: String,
default: ''
},
thumbnail: {
type: String,
default: ''
},
artist: {
type: String,
default: ''
},
isControls: {
type: Boolean,
default: true
item: {
type: Object,
default: () => ({})
},
})
const emitter = defineEmits(['closeModel'])
const video = ref(null)
const tracks = ref([])
const sources = ref([])
const thumbnail = ref('')
const artist = ref('')
const title = ref('')
const isAudio = ref(false)
let player = null;
let hls = null;
@ -65,7 +61,43 @@ const eventFunc = e => {
}
}
onMounted(() => {
onMounted(async () => {
const response = await (await request(makeDownload(config, props.item, 'api/file/info'))).json()
if (props.item.extras?.thumbnail) {
thumbnail.value = '/api/thumbnail?url=' + encodePath(props.item.extras.thumbnail)
}
sources.value.push({
src: makeDownload(config, props.item, 'api/download'),
onerror: e => src_error(e),
})
if (props.item.extras?.channel) {
artist.value = props.item.extras.channel
}
if (!artist.value && props.item.extras?.uploader) {
artist.value = props.item.extras.uploader
}
if (props.item?.title) {
title.value = props.item.title
}
if (!props.item.extras?.is_video && props.item.extras?.is_audio) {
isAudio.value = true
}
response.sidecar.forEach((cap, id) => {
tracks.value.push({
kind: "captions",
label: cap.name,
lang: cap.lang,
file: `${makeDownload(config, { filename: cap.file }, 'api/player/subtitle')}.vtt`
})
})
if (/(iPhone|iPod|iPad).*AppleWebKit/i.test(navigator.userAgent)) {
document.documentElement.style.setProperty('--webkit-text-track-display', 'block');
}
@ -86,23 +118,23 @@ onUnmounted(() => {
window.removeEventListener('keydown', eventFunc)
if (props.title) {
if (title.value) {
window.document.title = 'YTPTube'
}
})
const prepareVideoPlayer = () => {
let mediaMetadata = {
title: props.title,
title: title.value,
};
if (props.thumbnail) {
if (thumbnail.value) {
mediaMetadata['artwork'] = [
{ src: props.thumbnail, sizes: '1920x1080', type: 'image/jpeg' },
{ src: thumbnail.value, sizes: '1920x1080', type: 'image/jpeg' },
]
}
if (props.artist) {
mediaMetadata['artist'] = props.artist
if (artist.value) {
mediaMetadata['artist'] = artist.value
}
let opts = {
@ -121,28 +153,47 @@ const prepareVideoPlayer = () => {
enabled: true,
key: 'plyr'
},
poster: props.thumbnail,
artist: props.artist,
title: props.title,
artist: artist.value,
mediaMetadata: mediaMetadata,
captions: {
update: true,
}
},
};
if (props.artist) {
opts.artist = props.artist
if (artist.value) {
opts.artist = artist.value
}
if (props.title) {
opts.title = props.title
if (title.value) {
opts.title = title.value
}
if (props.thumbnail) {
opts.poster = props.thumbnail
if (thumbnail.value) {
opts.poster = thumbnail.value
}
player = new Plyr(video.value, opts);
player.source = {
type: isAudio.value ? 'audio' : 'video',
title: title.value,
poster: thumbnail.value,
};
if (title.value) {
window.document.title = `YTPTube - Playing: ${title.value}`
}
}
const src_error = () => {
if (hls) {
return
}
console.warn('Direct play failed, trying HLS.');
attach_hls(makeDownload(config, props.item, 'm3u8'));
}
const attach_hls = link => {
hls = new Hls({
debug: false,
enableWorker: true,
@ -151,14 +202,7 @@ const prepareVideoPlayer = () => {
fragLoadingTimeOut: 200000,
});
hls.loadSource(props.link)
if (video.value) {
hls.attachMedia(video.value)
}
if (props.title) {
window.document.title = `YTPTube - Playing: ${props.title}`
}
hls.loadSource(link)
hls.attachMedia(video.value)
}
</script>

View file

@ -350,7 +350,10 @@ const getQueryParams = (url = window.location.search) => Object.fromEntries(new
* @returns {string} The download URL
*/
const makeDownload = (config, item, base = 'api/download') => {
let baseDir = 'api/download' === base ? `${base}/` : 'api/player/playlist/';
let baseDir = 'api/player/m3u8/video/';
if ('m3u8' !== base) {
baseDir = `${base}/`;
}
if (item.folder) {
item.folder = item.folder.replace(/#/g, '%23');