Merge pull request #382 from arabcoders/dev
Some checks failed
Build WebView wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, windows-latest) (push) Has been cancelled

Added better fallback to hls.
This commit is contained in:
Abdulmohsen 2025-08-23 21:44:50 +03:00 committed by GitHub
commit 8473618726
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 764 additions and 448 deletions

View file

@ -95,6 +95,7 @@
"rejecttitle", "rejecttitle",
"remux", "remux",
"rtime", "rtime",
"rvfc",
"SIGUSR", "SIGUSR",
"smhd", "smhd",
"socketio", "socketio",

View file

@ -5,35 +5,19 @@ Python wrapper for ffprobe command line tool. ffprobe must exist in the path.
import asyncio import asyncio
import functools import functools
import json import json
import logging
import operator import operator
import os import os
import subprocess import subprocess # qa: ignore
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING
import anyio import anyio
if TYPE_CHECKING:
from app.library.cache import Cache
# parameter-less decorator LOG: logging.Logger = logging.getLogger(__name__)
def async_lru_cache_decorator(async_function):
@functools.lru_cache
def cached_async_function(*args, **kwargs):
coroutine = async_function(*args, **kwargs)
return asyncio.ensure_future(coroutine)
return cached_async_function
# decorator with options
def async_lru_cache(*lru_cache_args, **lru_cache_kwargs):
def async_lru_cache_decorator(async_function):
@functools.lru_cache(*lru_cache_args, **lru_cache_kwargs)
def cached_async_function(*args, **kwargs):
coroutine = async_function(*args, **kwargs)
return asyncio.ensure_future(coroutine)
return cached_async_function
return async_lru_cache_decorator
class FFProbeError(Exception): class FFProbeError(Exception):
@ -254,7 +238,6 @@ class FFProbeResult:
} }
@async_lru_cache(maxsize=512)
async def ffprobe(file: str) -> FFProbeResult: async def ffprobe(file: str) -> FFProbeResult:
""" """
Run ffprobe on a file and return the parsed data as a dictionary. Run ffprobe on a file and return the parsed data as a dictionary.
@ -266,6 +249,21 @@ async def ffprobe(file: str) -> FFProbeResult:
dict: A dictionary containing the parsed data. dict: A dictionary containing the parsed data.
""" """
from app.library.Services import Services
f = Path(file)
if not f.exists():
msg = f"No such media file '{file}'."
raise OSError(msg)
cache: Cache | None = Services.get_instance().get("cache")
cache_key: str = f"ffprobe:{f!s}:{f.stat().st_size}"
if cache and (cached := cache.get(cache_key)):
LOG.debug(f"ffprobe cache hit for '{cache_key}'")
return cached
try: try:
async with await anyio.open_file(os.devnull, "w") as tempf: async with await anyio.open_file(os.devnull, "w") as tempf:
await asyncio.create_subprocess_exec( await asyncio.create_subprocess_exec(
@ -279,11 +277,7 @@ async def ffprobe(file: str) -> FFProbeResult:
msg = "ffprobe not found." msg = "ffprobe not found."
raise OSError(msg) from e raise OSError(msg) from e
if not Path(file).exists(): args = ["-v", "quiet", "-of", "json", "-show_streams", "-show_format", str(f)]
msg = f"No such media file '{file}'."
raise OSError(msg)
args = ["-v", "quiet", "-of", "json", "-show_streams", "-show_format", file]
p = await asyncio.create_subprocess_exec( p = await asyncio.create_subprocess_exec(
"ffprobe", "ffprobe",
@ -316,4 +310,7 @@ async def ffprobe(file: str) -> FFProbeResult:
elif stream.is_attachment(): elif stream.is_attachment():
result.attachment.append(stream) result.attachment.append(stream)
if cache:
cache.set(cache_key, result, ttl=300)
return result return result

View file

@ -8,6 +8,7 @@
max-width: 80vw; max-width: 80vw;
} }
</style> </style>
<template> <template>
<div v-if="infoLoaded"> <div v-if="infoLoaded">
<video class="player" ref="video" :poster="uri(thumbnail)" :title="title" playsinline controls <video class="player" ref="video" :poster="uri(thumbnail)" :title="title" playsinline controls
@ -28,32 +29,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import Hls from 'hls.js' import Hls from 'hls.js'
import type { StoreItem } from '~/types/store'
import { disableOpacity, enableOpacity } from '~/utils' import { disableOpacity, enableOpacity } from '~/utils'
type video_track_element = { import type { StoreItem } from '~/types/store'
file: string, import type { file_info, video_source_element, video_track_element } from '~/types/video'
kind: string,
label: string,
lang: string,
}
type video_source_element = {
src: string,
type: string,
onerror: (e: Event) => void,
}
type file_info = {
title: string,
mimetype: string,
sidecar?: {
image?: Array<{ file: string }>,
text?: Array<{ file: string }>,
subtitle?: Array<{ name: string, lang: string, file: string }>,
}
error?: string,
}
const config = useConfigStore() const config = useConfigStore()
const toast = useNotification() const toast = useNotification()
@ -67,7 +46,7 @@ const props = defineProps({
const emitter = defineEmits(['closeModel']) const emitter = defineEmits(['closeModel'])
const video = useTemplateRef<HTMLMediaElement>('video') const video = useTemplateRef<HTMLVideoElement>('video')
const tracks = ref<Array<video_track_element>>([]) const tracks = ref<Array<video_track_element>>([])
const sources = ref<Array<video_source_element>>([]) const sources = ref<Array<video_source_element>>([])
@ -75,43 +54,153 @@ const thumbnail = ref('/images/placeholder.png')
const artist = ref('') const artist = ref('')
const title = ref('') const title = ref('')
const isAudio = ref(false) const isAudio = ref(false)
const isApple = /(iPhone|iPod|iPad).*AppleWebKit/i.test(navigator.userAgent) const hasVideoStream = ref(false)
const volume = useStorage('player_volume', 1) const volume = useStorage('player_volume', 1)
const notFirefox = !navigator.userAgent.toLowerCase().includes('firefox') const notFirefox = !navigator.userAgent.toLowerCase().includes('firefox')
const infoLoaded = ref(false) const infoLoaded = ref(false)
const destroyed = ref(false) const destroyed = ref(false)
const isApple = /(iPhone|iPod|iPad).*AppleWebKit/i.test(navigator.userAgent)
const havePoster = ref(false)
let unbindMediaSessionListeners: null | (() => void) = null
let hls: Hls | null = null let hls: Hls | null = null
let detachDecodeGuard: null | (() => void) = null
const handle_event = (e: KeyboardEvent) => { const handle_event = (e: KeyboardEvent) => {
if (e.key !== 'Escape') { if ('Escape' !== e.key) {
return return
} }
emitter('closeModel') emitter('closeModel')
} }
const bindMediaSessionListeners = (el: HTMLVideoElement) => {
const onLoadedMetadata = (e: Event) => updateMediaSessionPosition(e.currentTarget)
const onTimeUpdate = (e: Event) => updateMediaSessionPosition(e.currentTarget)
const onRateChange = (e: Event) => updateMediaSessionPosition(e.currentTarget)
const onSeeked = (e: Event) => updateMediaSessionPosition(e.currentTarget)
const onPause = async (e: Event) => {
const target = (e.currentTarget as HTMLVideoElement) ?? null
if (!target || destroyed.value) {
return
}
const dataUrl = await captureFrame(target)
if (dataUrl) {
thumbnail.value = dataUrl
havePoster.value = true
applyMediaSessionMetadata()
}
}
el.addEventListener('loadedmetadata', onLoadedMetadata)
el.addEventListener('timeupdate', onTimeUpdate)
el.addEventListener('ratechange', onRateChange)
el.addEventListener('seeked', onSeeked)
el.addEventListener('pause', onPause)
return () => {
el.removeEventListener('loadedmetadata', onLoadedMetadata)
el.removeEventListener('timeupdate', onTimeUpdate)
el.removeEventListener('ratechange', onRateChange)
el.removeEventListener('seeked', onSeeked)
el.removeEventListener('pause', onPause)
}
}
const updateMediaSessionPosition = (target: EventTarget | null) => {
if (false === ('mediaSession' in navigator)) {
return
}
const el = (target as HTMLVideoElement) ?? null
if (!el || destroyed.value) {
return
}
const d = el.duration
if (false === Number.isFinite(d) || 0 >= d) {
return
}
try {
navigator.mediaSession.setPositionState({
duration: d,
playbackRate: el.playbackRate,
position: el.currentTime,
})
} catch { }
}
const volume_change_handler = () => { const volume_change_handler = () => {
if (!video.value) { const el = video.value
if (!el) {
return
}
volume.value = el.volume
updateMediaSessionPosition(el)
}
/**
* Unified frame capture helper (merges previous captureCurrentFrame/captureFirstFramePoster logic).
* Returns a JPEG data URL or '' on failure.
*/
const captureFrame = async (el: HTMLVideoElement): Promise<string> => {
if (!el || destroyed.value) {
return ''
}
if (0 === el.videoWidth || 0 === el.videoHeight) {
return ''
}
const w = el.videoWidth
const h = el.videoHeight
try {
if ('OffscreenCanvas' in window) {
const c = new (window as any).OffscreenCanvas(w, h)
const ctx = c.getContext('2d')
if (!ctx) { return '' }
ctx.drawImage(el, 0, 0, w, h)
const blob = await c.convertToBlob({ type: 'image/jpeg', quality: 0.86 })
return await new Promise<string>(r => {
const fr = new FileReader()
fr.onload = () => r(String(fr.result))
fr.readAsDataURL(blob)
})
} else {
const c = document.createElement('canvas')
c.width = w
c.height = h
const ctx = c.getContext('2d')
if (!ctx) { return '' }
ctx.drawImage(el, 0, 0, w, h)
return c.toDataURL('image/jpeg', 0.86)
}
} catch {
return ''
}
}
const captureFirstFramePoster = async (el: HTMLVideoElement): Promise<void> => {
if (!el || destroyed.value) {
return
}
if (havePoster.value) {
return
}
if (0 === el.videoWidth || 0 === el.videoHeight) {
return return
} }
volume.value = video.value.volume const dataUrl = await captureFrame(el)
if (!dataUrl) {
if (false === ("mediaSession" in navigator)) {
return return
} }
navigator.mediaSession.setPositionState({ thumbnail.value = dataUrl
duration: video.value.duration, havePoster.value = true
playbackRate: video.value.playbackRate, applyMediaSessionMetadata()
position: video.value.currentTime,
})
} }
onMounted(async () => { onMounted(async () => {
disableOpacity() disableOpacity()
const req = await request(makeDownload(config, props.item, 'api/file/info')) const req = await request(makeDownload(config, props.item, 'api/file/info'))
const response: file_info = await req.json() const response: file_info = await req.json()
if (!req.ok) { if (!req.ok) {
@ -124,24 +213,37 @@ onMounted(async () => {
if (props.item.extras?.thumbnail) { if (props.item.extras?.thumbnail) {
thumbnail.value = '/api/thumbnail?url=' + encodePath(props.item.extras.thumbnail) thumbnail.value = '/api/thumbnail?url=' + encodePath(props.item.extras.thumbnail)
havePoster.value = true
} else { } else {
if (response.sidecar?.image?.[0]?.file) { if (response.sidecar?.image?.[0]?.file) {
thumbnail.value = makeDownload(config, { "filename": response.sidecar.image[0].file }) thumbnail.value = makeDownload(config, { 'filename': response.sidecar.image[0].file })
havePoster.value = true
}
}
hasVideoStream.value = Array.isArray(response.ffprobe?.video)
&& response.ffprobe.video.some(s => 'video' === s.codec_type)
if (!props.item.extras?.is_video && props.item.extras?.is_audio) {
isAudio.value = true
} else {
if (false === hasVideoStream.value) {
isAudio.value = true
} }
} }
// -- check if mimetype is video/mp4 and device is apple
// -- as always apple, apple like to be snowflakes.
if (isApple) { if (isApple) {
const allowedCodec = response.mimetype && response.mimetype.includes('video/mp4') const allowedCodec = response.mimetype && response.mimetype.includes('video/mp4')
const src = makeDownload(config, props.item, allowedCodec ? 'api/download' : 'm3u8')
sources.value.push({ sources.value.push({
src: makeDownload(config, props.item, allowedCodec ? 'api/download' : 'm3u8'), src,
type: allowedCodec ? response.mimetype : 'application/x-mpegURL', type: allowedCodec ? response.mimetype : 'application/x-mpegURL',
onerror: (e: Event) => src_error(), onerror: (e: Event) => src_error(),
}) })
} else { } else {
const src = makeDownload(config, props.item, 'api/download')
sources.value.push({ sources.value.push({
src: makeDownload(config, props.item, 'api/download'), src,
type: response.mimetype, type: response.mimetype,
onerror: e => src_error(), onerror: e => src_error(),
}) })
@ -157,20 +259,19 @@ onMounted(async () => {
if (props.item?.title) { if (props.item?.title) {
title.value = props.item.title title.value = props.item.title
} } else {
else {
if (response?.title) { if (response?.title) {
title.value = response.title title.value = response.title
} else {
if (response.ffprobe?.metadata?.tags?.title) {
title.value = response.ffprobe.metadata.tags.title
}
} }
} }
if (!props.item.extras?.is_video && props.item.extras?.is_audio) {
isAudio.value = true
}
response.sidecar?.subtitle?.forEach((cap, _) => { response.sidecar?.subtitle?.forEach((cap, _) => {
tracks.value.push({ tracks.value.push({
kind: "captions", kind: 'captions',
label: cap.name, label: cap.name,
lang: cap.lang, lang: cap.lang,
file: `${makeDownload(config, { filename: cap.file }, 'api/player/subtitle')}.vtt` file: `${makeDownload(config, { filename: cap.file }, 'api/player/subtitle')}.vtt`
@ -185,9 +286,28 @@ onMounted(async () => {
await nextTick() await nextTick()
prepareVideoPlayer() prepareVideoPlayer()
if (video.value) {
unbindMediaSessionListeners = bindMediaSessionListeners(video.value)
}
document.addEventListener('keydown', handle_event) document.addEventListener('keydown', handle_event)
}) })
const applyMediaSessionMetadata = () => {
if (false === ('mediaSession' in navigator)) {
return
}
const meta: MediaMetadataInit = { title: title.value }
if (artist.value) {
meta.artist = artist.value
}
if (thumbnail.value) {
meta.artwork = [{ src: thumbnail.value, sizes: '1920x1080', type: 'image/jpeg' }]
}
try { navigator.mediaSession.metadata = new MediaMetadata(meta) } catch { }
}
onUpdated(() => prepareVideoPlayer()) onUpdated(() => prepareVideoPlayer())
onBeforeUnmount(() => { onBeforeUnmount(() => {
@ -198,6 +318,11 @@ onBeforeUnmount(() => {
document.removeEventListener('keydown', handle_event) document.removeEventListener('keydown', handle_event)
if (unbindMediaSessionListeners) {
unbindMediaSessionListeners()
unbindMediaSessionListeners = null
}
if (title.value) { if (title.value) {
window.document.title = 'YTPTube' window.document.title = 'YTPTube'
} }
@ -209,8 +334,12 @@ onBeforeUnmount(() => {
try { try {
video.value.pause() video.value.pause()
video.value.querySelectorAll("source").forEach(source => source.removeAttribute("src")) video.value.querySelectorAll('source').forEach(source => source.removeAttribute('src'))
video.value.removeEventListener('volumechange', volume_change_handler) video.value.removeEventListener('volumechange', volume_change_handler)
if (detachDecodeGuard) {
detachDecodeGuard()
detachDecodeGuard = null
}
video.value.load() video.value.load()
} }
catch (e) { catch (e) {
@ -223,21 +352,8 @@ const prepareVideoPlayer = () => {
return return
} }
if (false === ("mediaSession" in navigator)) { applyMediaSessionMetadata()
return
}
let mediaMetadata: MediaMetadataInit = { title: title.value }
if (thumbnail.value) {
mediaMetadata.artwork = [{ src: thumbnail.value, sizes: '1920x1080', type: 'image/jpeg' }]
}
if (artist.value) {
mediaMetadata.artist = artist.value
}
navigator.mediaSession.metadata = new MediaMetadata(mediaMetadata)
if (title.value) { if (title.value) {
window.document.title = `YTPTube - Playing: ${title.value}` window.document.title = `YTPTube - Playing: ${title.value}`
} }
@ -248,6 +364,102 @@ const prepareVideoPlayer = () => {
video.value.volume = volume.value video.value.volume = volume.value
video.value.addEventListener('volumechange', volume_change_handler) video.value.addEventListener('volumechange', volume_change_handler)
if (detachDecodeGuard) {
detachDecodeGuard()
detachDecodeGuard = null
}
if (hasVideoStream.value) {
if ('requestVideoFrameCallback' in video.value) {
; (video.value as any).requestVideoFrameCallback(() => captureFirstFramePoster(video.value!))
} else {
const tryOnce = () => captureFirstFramePoster(video.value!)
; (video.value as any).addEventListener('loadeddata', tryOnce, { once: true })
}
detachDecodeGuard = attachDecodeFailGuard(video.value, () => src_error())
}
}
function attachDecodeFailGuard(el: HTMLVideoElement, onFail: () => void): () => void {
let rvfcId: number | null = null
let timer: number | null = null
let armed = false
let done = false
const clearAll = () => {
if (null !== timer) {
clearTimeout(timer)
timer = null
}
if (null !== rvfcId && 'cancelVideoFrameCallback' in el) {
; (el as any).cancelVideoFrameCallback(rvfcId)
rvfcId = null
}
el.removeEventListener('loadeddata', onLoadedData)
el.removeEventListener('error', onError)
el.removeEventListener('emptied', onError)
}
const ok = () => {
if (done) return
done = true
clearAll()
}
const fail = () => {
if (done) return
done = true
clearAll()
onFail()
}
const decodedFrames = (): number => {
try {
const q = 'getVideoPlaybackQuality' in el ? (el as any).getVideoPlaybackQuality() : null
if (q && 'totalVideoFrames' in q) {
return Number(q.totalVideoFrames || 0)
}
const anyEl = el as any
if ('webkitDecodedFrameCount' in anyEl) {
return Number(anyEl.webkitDecodedFrameCount || 0)
}
} catch { }
return 0
}
const armCheck = () => {
if (armed || done) return
armed = true
if ('requestVideoFrameCallback' in el) {
rvfcId = (el as any).requestVideoFrameCallback(() => ok())
timer = window.setTimeout(() => fail(), 1000)
return
}
timer = window.setTimeout(() => {
if (0 === (el as any).videoWidth || 0 === (el as any).videoHeight || 0 === decodedFrames()) {
fail()
} else {
ok()
}
}, 800)
}
const onLoadedData = () => armCheck()
const onError = () => fail()
el.addEventListener('loadeddata', onLoadedData, { once: true })
el.addEventListener('error', onError)
el.addEventListener('emptied', onError)
if (4 <= el.readyState || 2 <= el.readyState) {
queueMicrotask(armCheck)
}
return clearAll
} }
const src_error = async () => { const src_error = async () => {
@ -258,7 +470,8 @@ const src_error = async () => {
if (destroyed.value) { if (destroyed.value) {
return return
} }
console.warn('Direct play failed, trying HLS.')
console.warn('Source failed to load, attempting HLS fallback...')
attach_hls(makeDownload(config, props.item, 'm3u8')) attach_hls(makeDownload(config, props.item, 'm3u8'))
} }
@ -275,6 +488,21 @@ const attach_hls = (link: string) => {
fragLoadingTimeOut: 200000, fragLoadingTimeOut: 200000,
}) })
hls.on(Hls.Events.MANIFEST_PARSED, () => {
applyMediaSessionMetadata()
})
hls.on(Hls.Events.LEVEL_LOADED, () => {
if (video.value) {
if ('requestVideoFrameCallback' in video.value) {
; (video.value as any).requestVideoFrameCallback(() => captureFirstFramePoster(video.value!))
} else {
const once = () => captureFirstFramePoster(video.value!)
; (video.value as any).addEventListener('loadeddata', once, { once: true })
}
}
})
hls.loadSource(link) hls.loadSource(link)
hls.attachMedia(video.value) hls.attachMedia(video.value)
} }

85
ui/app/types/video.d.ts vendored Normal file
View file

@ -0,0 +1,85 @@
type video_track_element = {
file: string,
kind: string,
label: string,
lang: string,
}
type video_source_element = {
src: string,
type: string,
onerror: (e: Event) => void,
}
type FFProbeTags = {
major_brand?: string,
minor_version?: string,
compatible_brands?: string,
encoder?: string,
title?: string,
artist?: string,
album_artist?: string,
album?: string,
date?: string,
track?: string,
genre?: string,
}
type FFProbeMetadata = {
filename: string,
nb_streams: number,
nb_programs: number,
format_name: string,
format_long_name: string,
start_time: string,
duration: string,
size: string,
bit_rate: string,
tags: FFProbeTags
}
type FFProbeStream = {
index: number,
codec_name: string,
codec_type: string,
width?: number,
height?: number,
tags?: {
language?: string,
title?: string,
handler_name?: string,
},
channels?: number,
sample_rate?: string,
bit_rate?: string,
}
type FFProbeResult = {
metadata: FFProbeMetadata,
video: Array<FFProbeStream>,
audio: Array<FFProbeStream>,
subtitle: Array<FFProbeStream>,
attachment: Array<FFProbeStream>,
is_video: boolean,
is_audio: boolean,
}
type file_info = {
title: string,
ffprobe: FFProbeResult,
mimetype: string,
sidecar?: {
image?: Array<{ file: string }>,
text?: Array<{ file: string }>,
subtitle?: Array<{ name: string, lang: string, file: string }>,
},
error?: string,
}
export type {
video_track_element,
video_source_element,
FFProbeResult,
file_info,
};

View file

@ -25,7 +25,7 @@
"nuxt": "^4.0.3", "nuxt": "^4.0.3",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"vue": "^3.5.18", "vue": "^3.5.19",
"vue-router": "^4.5.1", "vue-router": "^4.5.1",
"vue-toastification": "2.0.0-rc.5" "vue-toastification": "2.0.0-rc.5"
} }

File diff suppressed because it is too large Load diff

View file

@ -1572,11 +1572,11 @@ wheels = [
[[package]] [[package]]
name = "yt-dlp" name = "yt-dlp"
version = "2025.8.20" version = "2025.8.22"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c8/af/d3c81af35ae2aef148d0ff78f001650ce5a7ca73fbd3b271eb9aab4c56ee/yt_dlp-2025.8.20.tar.gz", hash = "sha256:da873bcf424177ab5c3b701fa94ea4cdac17bf3aec5ef37b91f530c90def7bcf", size = 3037484 } sdist = { url = "https://files.pythonhosted.org/packages/40/98/b077bebdc5c759a3f7af3ed3a2a5345ad1145c61963b476469b840ac84ce/yt_dlp-2025.8.22.tar.gz", hash = "sha256:d1846bbb7edbcd2a0d4a2d76c7a2124868de9ea3b3959a8cb8219e3f7cb5c335", size = 3037631 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/33/e8/ebd888100684c10799897296e3061c19ba5559b641f8da218bf48229a815/yt_dlp-2025.8.20-py3-none-any.whl", hash = "sha256:073c97e2a3f9cd0fa6a76142c4ef46ca62b2575c37eaf80d8c3718fd6f3277eb", size = 3266841 }, { url = "https://files.pythonhosted.org/packages/f4/06/e9e5e85969bd85142b004577915f33356ee0d5e20b79af8dd40b8bbfc96f/yt_dlp-2025.8.22-py3-none-any.whl", hash = "sha256:b8c71fe4516170dea60c6e5c54e2d45654693b8dc273cad060f22199476ec979", size = 3266783 },
] ]
[[package]] [[package]]