try to direct play video first and fallback to hls if we encounter error.
This commit is contained in:
parent
259eae251a
commit
b7b72cf21e
8 changed files with 186 additions and 121 deletions
3
.vscode/launch.json
vendored
3
.vscode/launch.json
vendored
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue