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_API_URL": "http://localhost:8081/api/",
"NUXT_PUBLIC_WSS": ":8081/", "NUXT_PUBLIC_WSS": ":8081/",
}, },
"console": "internalConsole" "console": "internalConsole",
"outputCapture": "std",
}, },
{ {
"name": "Python: main.py", "name": "Python: main.py",

View file

@ -38,6 +38,7 @@ from .Utils import (
StreamingError, StreamingError,
arg_converter, arg_converter,
calc_download_path, calc_download_path,
get_sidecar_subtitles,
get_video_info, get_video_info,
validate_url, validate_url,
validate_uuid, validate_uuid,
@ -1178,6 +1179,46 @@ class HttpAPI(Common):
except Exception as e: except Exception as e:
return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code) 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") @route("GET", "api/youtube/auth")
async def is_authenticated(self, request: Request) -> Response: async def is_authenticated(self, request: Request) -> Response:
""" """

View file

@ -1,13 +1,10 @@
import glob
import re
from pathlib import Path from pathlib import Path
from urllib.parse import quote from urllib.parse import quote
from aiohttp.web import HTTPFound, Response from aiohttp.web import HTTPFound, Response
from .ffprobe import ffprobe from .ffprobe import ffprobe
from .Subtitle import Subtitle from .Utils import StreamingError, calc_download_path, check_id, get_sidecar_subtitles
from .Utils import StreamingError, calc_download_path, check_id
class Playlist: class Playlist:
@ -41,28 +38,19 @@ class Playlist:
msg = f"Unable to get '{rFile}' duration." msg = f"Unable to get '{rFile}' duration."
raise StreamingError(msg) raise StreamingError(msg)
duration: float = float(ff.metadata.get("duration"))
playlist = [] playlist = []
playlist.append("#EXTM3U") playlist.append("#EXTM3U")
subs = "" subs = ""
index = 0 duration: float = float(ff.metadata.get("duration"))
for item in self.get_sidecar_files(rFile): for sub_file in get_sidecar_subtitles(rFile):
if item.suffix not in Subtitle.allowed_extensions: lang = sub_file["lang"]
continue item = sub_file["file"]
name = sub_file["name"]
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"' subs = ',SUBTITLES="subs"'
url = f"{self.url}api/player/m3u8/subtitle/{quote(str(Path(file).with_name(item.name)))}.m3u8?duration={duration}" 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( playlist.append(
f'#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="{name}",DEFAULT=NO,AUTOSELECT=NO,FORCED=NO,LANGUAGE="{lang}",URI="{url}"' 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") playlist.append(f"{self.url}api/player/m3u8/video/{quote(file)}.m3u8")
return "\n".join(playlist) 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 copy
import glob
import ipaddress import ipaddress
import json import json
import logging import logging
@ -27,6 +28,8 @@ IGNORED_KEYS: tuple[str] = (
) )
YTDLP_INFO_CLS: yt_dlp.YoutubeDL = None YTDLP_INFO_CLS: yt_dlp.YoutubeDL = None
ALLOWED_SUBS_EXTENSIONS: tuple[str] = (".srt", ".vtt", ".ass")
class StreamingError(Exception): class StreamingError(Exception):
"""Raised when an error occurs during streaming.""" """Raised when an error occurs during streaming."""
@ -524,3 +527,27 @@ def validate_uuid(uuid_str: str, version: int = 4) -> bool:
return True return True
except ValueError: except ValueError:
return False 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 import json
from pathlib import Path
class Encoder(json.JSONEncoder): class Encoder(json.JSONEncoder):
@ -9,10 +10,14 @@ class Encoder(json.JSONEncoder):
""" """
def default(self, o): def default(self, o):
if isinstance(o, object) and hasattr(o, "serialize"): if isinstance(o, Path):
return o.serialize() return str(o)
if isinstance(o, object) and hasattr(o, "__dict__"): if isinstance(o, object):
return o.__dict__ if hasattr(o, "serialize"):
return o.serialize()
if hasattr(o, "__dict__"):
return o.__dict__
return json.JSONEncoder.default(self, o) return json.JSONEncoder.default(self, o)

View file

@ -235,12 +235,11 @@
</p> </p>
</div> </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-background" @click="closeVideo"></div>
<div class="modal-content"> <div class="modal-content">
<VideoPlayer type="default" :link="video_link" :isMuted="false" autoplay="true" :isControls="true" <VideoPlayer type="default" :isMuted="false" autoplay="true" :isControls="true" :item="video_item"
:title="video_title" :thumbnail="video_thumbnail" :artist="video_artist" class="is-fullwidth" class="is-fullwidth" @closeModel="closeVideo" />
@closeModel="closeVideo" />
</div> </div>
<button class="modal-close is-large" aria-label="close" @click="closeVideo"></button> <button class="modal-close is-large" aria-label="close" @click="closeVideo"></button>
</div> </div>
@ -264,36 +263,10 @@ const showCompleted = useStorage('showCompleted', true)
const hideThumbnail = useStorage('hideThumbnailHistory', false) const hideThumbnail = useStorage('hideThumbnailHistory', false)
const direction = useStorage('sortCompleted', 'desc') const direction = useStorage('sortCompleted', 'desc')
const video_link = ref('') const video_item = ref(null)
const video_title = ref('')
const video_thumbnail = ref('')
const video_artist = ref('')
const playVideo = item => { const playVideo = item => video_item.value = item
video_thumbnail.value = ''; const closeVideo = () => video_item.value = null
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 = ''
}
watch(masterSelectAll, (value) => { watch(masterSelectAll, (value) => {
for (const key in stateStore.history) { for (const key in stateStore.history) {

View file

@ -18,8 +18,10 @@
<template> <template>
<div> <div>
<video ref="video" :poster="thumbnail" :controls="isControls" :title="title" playsinline> <video id="player" ref="video" :poster="thumbnail" :title="title" playsinline>
<source :src="link" type="application/x-mpegURL" /> <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> </video>
</div> </div>
</template> </template>
@ -29,33 +31,27 @@ import { onMounted, onUpdated, ref, defineProps, defineEmits, onUnmounted } from
import Hls from 'hls.js' import Hls from 'hls.js'
import Plyr from 'plyr' import Plyr from 'plyr'
import 'plyr/dist/plyr.css' import 'plyr/dist/plyr.css'
import { makeDownload } from '~/utils/index'
const config = useConfigStore()
const props = defineProps({ const props = defineProps({
link: { item: {
type: String, type: Object,
default: '' default: () => ({})
},
title: {
type: String,
default: ''
},
thumbnail: {
type: String,
default: ''
},
artist: {
type: String,
default: ''
},
isControls: {
type: Boolean,
default: true
}, },
}) })
const emitter = defineEmits(['closeModel']) const emitter = defineEmits(['closeModel'])
const video = ref(null) 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 player = null;
let hls = 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)) { if (/(iPhone|iPod|iPad).*AppleWebKit/i.test(navigator.userAgent)) {
document.documentElement.style.setProperty('--webkit-text-track-display', 'block'); document.documentElement.style.setProperty('--webkit-text-track-display', 'block');
} }
@ -86,23 +118,23 @@ onUnmounted(() => {
window.removeEventListener('keydown', eventFunc) window.removeEventListener('keydown', eventFunc)
if (props.title) { if (title.value) {
window.document.title = 'YTPTube' window.document.title = 'YTPTube'
} }
}) })
const prepareVideoPlayer = () => { const prepareVideoPlayer = () => {
let mediaMetadata = { let mediaMetadata = {
title: props.title, title: title.value,
}; };
if (props.thumbnail) { if (thumbnail.value) {
mediaMetadata['artwork'] = [ mediaMetadata['artwork'] = [
{ src: props.thumbnail, sizes: '1920x1080', type: 'image/jpeg' }, { src: thumbnail.value, sizes: '1920x1080', type: 'image/jpeg' },
] ]
} }
if (props.artist) { if (artist.value) {
mediaMetadata['artist'] = props.artist mediaMetadata['artist'] = artist.value
} }
let opts = { let opts = {
@ -121,28 +153,47 @@ const prepareVideoPlayer = () => {
enabled: true, enabled: true,
key: 'plyr' key: 'plyr'
}, },
poster: props.thumbnail, artist: artist.value,
artist: props.artist,
title: props.title,
mediaMetadata: mediaMetadata, mediaMetadata: mediaMetadata,
captions: { captions: {
update: true, update: true,
} },
}; };
if (props.artist) { if (artist.value) {
opts.artist = props.artist opts.artist = artist.value
} }
if (props.title) { if (title.value) {
opts.title = props.title opts.title = title.value
} }
if (props.thumbnail) { if (thumbnail.value) {
opts.poster = props.thumbnail opts.poster = thumbnail.value
} }
player = new Plyr(video.value, opts); 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({ hls = new Hls({
debug: false, debug: false,
enableWorker: true, enableWorker: true,
@ -151,14 +202,7 @@ const prepareVideoPlayer = () => {
fragLoadingTimeOut: 200000, fragLoadingTimeOut: 200000,
}); });
hls.loadSource(props.link) hls.loadSource(link)
hls.attachMedia(video.value)
if (video.value) {
hls.attachMedia(video.value)
}
if (props.title) {
window.document.title = `YTPTube - Playing: ${props.title}`
}
} }
</script> </script>

View file

@ -350,7 +350,10 @@ const getQueryParams = (url = window.location.search) => Object.fromEntries(new
* @returns {string} The download URL * @returns {string} The download URL
*/ */
const makeDownload = (config, item, base = 'api/download') => { 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) { if (item.folder) {
item.folder = item.folder.replace(/#/g, '%23'); item.folder = item.folder.replace(/#/g, '%23');