diff --git a/.vscode/launch.json b/.vscode/launch.json
index 96b9c850..da500c43 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -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",
diff --git a/app/library/HttpAPI.py b/app/library/HttpAPI.py
index 2479085e..4ffef7b1 100644
--- a/app/library/HttpAPI.py
+++ b/app/library/HttpAPI.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:
"""
diff --git a/app/library/Playlist.py b/app/library/Playlist.py
index a83074ca..befd9193 100644
--- a/app/library/Playlist.py
+++ b/app/library/Playlist.py
@@ -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\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
diff --git a/app/library/Utils.py b/app/library/Utils.py
index b8e3fe7d..bad19221 100644
--- a/app/library/Utils.py
+++ b/app/library/Utils.py
@@ -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\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
diff --git a/app/library/encoder.py b/app/library/encoder.py
index 2a788649..0ed61c57 100644
--- a/app/library/encoder.py
+++ b/app/library/encoder.py
@@ -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)
diff --git a/ui/components/History.vue b/ui/components/History.vue
index b5aab95d..44be0a0f 100644
--- a/ui/components/History.vue
+++ b/ui/components/History.vue
@@ -235,12 +235,11 @@
-
+
@@ -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) {
diff --git a/ui/components/VideoPlayer.vue b/ui/components/VideoPlayer.vue
index e1b32a44..e27df2f5 100644
--- a/ui/components/VideoPlayer.vue
+++ b/ui/components/VideoPlayer.vue
@@ -18,8 +18,10 @@
-
@@ -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)
}
diff --git a/ui/utils/index.js b/ui/utils/index.js
index 47960e61..292eabff 100644
--- a/ui/utils/index.js
+++ b/ui/utils/index.js
@@ -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');