better fallback if file was renamed and retains the same ID
This commit is contained in:
parent
f0836ddd77
commit
ea98562944
6 changed files with 184 additions and 141 deletions
|
|
@ -11,6 +11,7 @@ import uuid
|
||||||
from collections.abc import Awaitable
|
from collections.abc import Awaitable
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
import httpx
|
import httpx
|
||||||
|
|
@ -37,7 +38,7 @@ from .Utils import (
|
||||||
IGNORED_KEYS,
|
IGNORED_KEYS,
|
||||||
StreamingError,
|
StreamingError,
|
||||||
arg_converter,
|
arg_converter,
|
||||||
calc_download_path,
|
get_file,
|
||||||
get_mime_type,
|
get_mime_type,
|
||||||
get_sidecar_subtitles,
|
get_sidecar_subtitles,
|
||||||
get_video_info,
|
get_video_info,
|
||||||
|
|
@ -119,7 +120,7 @@ class HttpAPI(Common):
|
||||||
HttpAPI: The instance of the HttpAPI.
|
HttpAPI: The instance of the HttpAPI.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
app.middlewares.append(HttpAPI.middle_wares())
|
app.middlewares.append(HttpAPI.middle_wares(download_path=self.config.download_path))
|
||||||
if self.config.auth_username and self.config.auth_password:
|
if self.config.auth_username and self.config.auth_password:
|
||||||
app.middlewares.append(HttpAPI.basic_auth(self.config.auth_username, self.config.auth_password))
|
app.middlewares.append(HttpAPI.basic_auth(self.config.auth_username, self.config.auth_password))
|
||||||
|
|
||||||
|
|
@ -312,9 +313,19 @@ class HttpAPI(Common):
|
||||||
return middleware_handler
|
return middleware_handler
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def middle_wares() -> Awaitable:
|
def middle_wares(download_path: str) -> Awaitable:
|
||||||
@web.middleware
|
@web.middleware
|
||||||
async def middleware_handler(request: Request, handler: RequestHandler) -> Response:
|
async def middleware_handler(request: Request, handler: RequestHandler) -> Response:
|
||||||
|
if request.path.startswith("/api/download"):
|
||||||
|
realFile, status = get_file(download_path=download_path, file=request.path.replace("/api/download/", ""))
|
||||||
|
if web.HTTPFound.status_code == status:
|
||||||
|
return Response(
|
||||||
|
status=status,
|
||||||
|
headers={
|
||||||
|
"Location": f"/api/download/{quote(str(realFile).replace(download_path, '').strip('/'))}"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
response = await handler(request)
|
response = await handler(request)
|
||||||
|
|
||||||
if isinstance(response, web.FileResponse):
|
if isinstance(response, web.FileResponse):
|
||||||
|
|
@ -918,25 +929,33 @@ class HttpAPI(Common):
|
||||||
file: str = request.match_info.get("file")
|
file: str = request.match_info.get("file")
|
||||||
|
|
||||||
if not file:
|
if not file:
|
||||||
raise web.HTTPBadRequest(text="file is required.")
|
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
text = await Playlist(url="/").make(download_path=self.config.download_path, file=file)
|
realFile, status = get_file(download_path=self.config.download_path, file=file)
|
||||||
if isinstance(text, Response):
|
if web.HTTPFound.status_code == status:
|
||||||
return text
|
return Response(
|
||||||
|
status=web.HTTPFound.status_code,
|
||||||
|
headers={
|
||||||
|
"Location": f"/api/player/playlist/{quote(str(realFile).replace(self.config.download_path, '').strip('/'))}.m3u8"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if web.HTTPNotFound.status_code == status:
|
||||||
|
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
|
||||||
|
|
||||||
|
return web.Response(
|
||||||
|
text=await Playlist(download_path=self.config.download_path, url="/").make(file=realFile),
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/x-mpegURL",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Access-Control-Max-Age": "300",
|
||||||
|
},
|
||||||
|
status=web.HTTPOk.status_code,
|
||||||
|
)
|
||||||
except StreamingError as e:
|
except StreamingError as e:
|
||||||
return web.json_response(data={"error": str(e)}, status=web.HTTPNotFound.status_code)
|
return web.json_response(data={"error": str(e)}, status=web.HTTPNotFound.status_code)
|
||||||
|
|
||||||
return web.Response(
|
|
||||||
text=text,
|
|
||||||
headers={
|
|
||||||
"Content-Type": "application/x-mpegURL",
|
|
||||||
"Cache-Control": "no-cache",
|
|
||||||
"Access-Control-Max-Age": "300",
|
|
||||||
},
|
|
||||||
status=web.HTTPOk.status_code,
|
|
||||||
)
|
|
||||||
|
|
||||||
@route("GET", "api/player/m3u8/{mode}/{file:.*}.m3u8")
|
@route("GET", "api/player/m3u8/{mode}/{file:.*}.m3u8")
|
||||||
async def m3u8(self, request: Request) -> Response:
|
async def m3u8(self, request: Request) -> Response:
|
||||||
"""
|
"""
|
||||||
|
|
@ -969,11 +988,24 @@ class HttpAPI(Common):
|
||||||
duration = float(duration)
|
duration = float(duration)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cls = M3u8("/")
|
cls = M3u8(download_path=self.config.download_path, url="/")
|
||||||
|
|
||||||
|
realFile, status = get_file(download_path=self.config.download_path, file=file)
|
||||||
|
if web.HTTPFound.status_code == status:
|
||||||
|
return Response(
|
||||||
|
status=status,
|
||||||
|
headers={
|
||||||
|
"Location": f"/api/player/m3u8/{mode}/{quote(str(realFile).replace(self.config.download_path, '').strip('/'))}.m3u8"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if web.HTTPNotFound.status_code == status:
|
||||||
|
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
|
||||||
|
|
||||||
if "subtitle" in mode:
|
if "subtitle" in mode:
|
||||||
text = await cls.make_subtitle(self.config.download_path, file, duration)
|
text = await cls.make_subtitle(file=realFile, duration=duration)
|
||||||
else:
|
else:
|
||||||
text = await cls.make_stream(self.config.download_path, file)
|
text = await cls.make_stream(file=realFile)
|
||||||
except StreamingError as e:
|
except StreamingError as e:
|
||||||
LOG.exception(e)
|
LOG.exception(e)
|
||||||
return web.json_response(data={"error": str(e)}, status=web.HTTPNotFound.status_code)
|
return web.json_response(data={"error": str(e)}, status=web.HTTPNotFound.status_code)
|
||||||
|
|
@ -1005,16 +1037,6 @@ class HttpAPI(Common):
|
||||||
sd: int = request.query.get("sd")
|
sd: int = request.query.get("sd")
|
||||||
vc: int = int(request.query.get("vc", 0))
|
vc: int = int(request.query.get("vc", 0))
|
||||||
ac: int = int(request.query.get("ac", 0))
|
ac: int = int(request.query.get("ac", 0))
|
||||||
file_path: str = os.path.normpath(os.path.join(self.config.download_path, file))
|
|
||||||
if not file_path.startswith(self.config.download_path):
|
|
||||||
return web.json_response(data={"error": "Invalid file path."}, status=web.HTTPBadRequest.status_code)
|
|
||||||
|
|
||||||
if request.if_modified_since:
|
|
||||||
lastMod = time.strftime(
|
|
||||||
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(os.path.getmtime(file_path), tz=UTC).timetuple()
|
|
||||||
)
|
|
||||||
if os.path.exists(file_path) and request.if_modified_since.timestamp() == os.path.getmtime(file_path):
|
|
||||||
return web.Response(status=web.HTTPNotModified.status_code, headers={"Last-Modified": lastMod})
|
|
||||||
|
|
||||||
if not file:
|
if not file:
|
||||||
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
|
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
@ -1022,7 +1044,26 @@ class HttpAPI(Common):
|
||||||
if not segment:
|
if not segment:
|
||||||
return web.json_response(data={"error": "segment id is required."}, status=web.HTTPBadRequest.status_code)
|
return web.json_response(data={"error": "segment id is required."}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
|
realFile, status = get_file(download_path=self.config.download_path, file=file)
|
||||||
|
if web.HTTPFound.status_code == status:
|
||||||
|
return Response(
|
||||||
|
status=status,
|
||||||
|
headers={
|
||||||
|
"Location": f"/api/player/segments/{segment}/{quote(str(realFile).replace(self.config.download_path, '').strip('/'))}.ts"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if web.HTTPNotFound.status_code == status:
|
||||||
|
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
|
||||||
|
|
||||||
|
mtime = realFile.stat().st_mtime
|
||||||
|
|
||||||
|
if request.if_modified_since and request.if_modified_since.timestamp() == mtime:
|
||||||
|
lastMod = time.strftime("%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple())
|
||||||
|
return web.Response(status=web.HTTPNotModified.status_code, headers={"Last-Modified": lastMod})
|
||||||
|
|
||||||
segmenter = Segments(
|
segmenter = Segments(
|
||||||
|
download_path=self.config.download_path,
|
||||||
index=int(segment),
|
index=int(segment),
|
||||||
duration=float(f"{float(sd if sd else M3u8.duration):.6f}"),
|
duration=float(f"{float(sd if sd else M3u8.duration):.6f}"),
|
||||||
vconvert=vc == 1,
|
vconvert=vc == 1,
|
||||||
|
|
@ -1030,7 +1071,7 @@ class HttpAPI(Common):
|
||||||
)
|
)
|
||||||
|
|
||||||
return web.Response(
|
return web.Response(
|
||||||
body=await segmenter.stream(path=self.config.download_path, file=file),
|
body=await segmenter.stream(file=realFile),
|
||||||
headers={
|
headers={
|
||||||
"Content-Type": "video/mpegts",
|
"Content-Type": "video/mpegts",
|
||||||
"X-Accel-Buffering": "no",
|
"X-Accel-Buffering": "no",
|
||||||
|
|
@ -1038,7 +1079,7 @@ class HttpAPI(Common):
|
||||||
"Pragma": "public",
|
"Pragma": "public",
|
||||||
"Cache-Control": f"public, max-age={time.time() + 31536000}",
|
"Cache-Control": f"public, max-age={time.time() + 31536000}",
|
||||||
"Last-Modified": time.strftime(
|
"Last-Modified": time.strftime(
|
||||||
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(os.path.getmtime(file_path), tz=UTC).timetuple()
|
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple()
|
||||||
),
|
),
|
||||||
"Expires": time.strftime(
|
"Expires": time.strftime(
|
||||||
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(time.time() + 31536000, tz=UTC).timetuple()
|
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(time.time() + 31536000, tz=UTC).timetuple()
|
||||||
|
|
@ -1060,22 +1101,30 @@ class HttpAPI(Common):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
file: str = request.match_info.get("file")
|
file: str = request.match_info.get("file")
|
||||||
file_path: str = os.path.normpath(os.path.join(self.config.download_path, file))
|
|
||||||
if not file_path.startswith(self.config.download_path):
|
|
||||||
return web.json_response(data={"error": "Invalid file path."}, status=web.HTTPBadRequest.status_code)
|
|
||||||
|
|
||||||
if request.if_modified_since:
|
|
||||||
lastMod = time.strftime(
|
|
||||||
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(os.path.getmtime(file_path), tz=UTC).timetuple()
|
|
||||||
)
|
|
||||||
if os.path.exists(file_path) and request.if_modified_since.timestamp() == os.path.getmtime(file_path):
|
|
||||||
return web.Response(status=web.HTTPNotModified.status_code, headers={"Last-Modified": lastMod})
|
|
||||||
|
|
||||||
if not file:
|
if not file:
|
||||||
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
|
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
|
realFile, status = get_file(download_path=self.config.download_path, file=file)
|
||||||
|
if web.HTTPFound.status_code == status:
|
||||||
|
return Response(
|
||||||
|
status=status,
|
||||||
|
headers={
|
||||||
|
"Location": f"/api/player/subtitle/{quote(str(realFile).replace(self.config.download_path, '').strip('/'))}.vtt"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if web.HTTPNotFound.status_code == status:
|
||||||
|
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
|
||||||
|
|
||||||
|
mtime = realFile.stat().st_mtime
|
||||||
|
|
||||||
|
if request.if_modified_since and request.if_modified_since.timestamp() == mtime:
|
||||||
|
lastMod = time.strftime("%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple())
|
||||||
|
return web.Response(status=web.HTTPNotModified.status_code, headers={"Last-Modified": lastMod})
|
||||||
|
|
||||||
return web.Response(
|
return web.Response(
|
||||||
body=await Subtitle().make(path=self.config.download_path, file=file),
|
body=await Subtitle(download_path=self.config.download_path).make(file=realFile),
|
||||||
headers={
|
headers={
|
||||||
"Content-Type": "text/vtt; charset=UTF-8",
|
"Content-Type": "text/vtt; charset=UTF-8",
|
||||||
"X-Accel-Buffering": "no",
|
"X-Accel-Buffering": "no",
|
||||||
|
|
@ -1083,7 +1132,7 @@ class HttpAPI(Common):
|
||||||
"Pragma": "public",
|
"Pragma": "public",
|
||||||
"Cache-Control": f"public, max-age={time.time() + 31536000}",
|
"Cache-Control": f"public, max-age={time.time() + 31536000}",
|
||||||
"Last-Modified": time.strftime(
|
"Last-Modified": time.strftime(
|
||||||
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(os.path.getmtime(file_path), tz=UTC).timetuple()
|
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple()
|
||||||
),
|
),
|
||||||
"Expires": time.strftime(
|
"Expires": time.strftime(
|
||||||
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(time.time() + 31536000, tz=UTC).timetuple()
|
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(time.time() + 31536000, tz=UTC).timetuple()
|
||||||
|
|
@ -1187,12 +1236,18 @@ class HttpAPI(Common):
|
||||||
return web.json_response(data={"error": "file is required."}, status=web.HTTPBadRequest.status_code)
|
return web.json_response(data={"error": "file is required."}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
realFile: str = calc_download_path(base_path=self.config.download_path, folder=file, create_path=False)
|
realFile, status = get_file(download_path=self.config.download_path, file=file)
|
||||||
if not os.path.exists(realFile) or not os.path.isfile(realFile):
|
if web.HTTPFound.status_code == status:
|
||||||
return web.json_response(
|
return Response(
|
||||||
data={"error": f"File '{file}' does not exist."}, status=web.HTTPNotFound.status_code
|
status=web.HTTPFound.status_code,
|
||||||
|
headers={
|
||||||
|
"Location": f"/api/file/ffprobe/{quote(str(realFile).replace(self.config.download_path, '').strip('/'))}"
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if web.HTTPNotFound.status_code == status:
|
||||||
|
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
|
||||||
|
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
data=await ffprobe(realFile), status=web.HTTPOk.status_code, dumps=self.encoder.encode
|
data=await ffprobe(realFile), status=web.HTTPOk.status_code, dumps=self.encoder.encode
|
||||||
)
|
)
|
||||||
|
|
@ -1216,13 +1271,17 @@ class HttpAPI(Common):
|
||||||
return web.json_response(data={"error": "file is required."}, status=web.HTTPBadRequest.status_code)
|
return web.json_response(data={"error": "file is required."}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
realFile: str = calc_download_path(base_path=self.config.download_path, folder=file, create_path=False)
|
realFile, status = get_file(download_path=self.config.download_path, file=file)
|
||||||
if not os.path.exists(realFile) or not os.path.isfile(realFile):
|
if web.HTTPFound.status_code == status:
|
||||||
return web.json_response(
|
return Response(
|
||||||
data={"error": f"File '{file}' does not exist."}, status=web.HTTPNotFound.status_code
|
status=web.HTTPFound.status_code,
|
||||||
|
headers={
|
||||||
|
"Location": f"/api/file/info/{quote(str(realFile).replace(self.config.download_path, '').strip('/'))}"
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
realFile = Path(realFile)
|
if web.HTTPNotFound.status_code == status:
|
||||||
|
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
|
||||||
|
|
||||||
ff_info = await ffprobe(realFile)
|
ff_info = await ffprobe(realFile)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import math
|
import math
|
||||||
import os
|
from pathlib import Path
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from .ffprobe import ffprobe
|
from .ffprobe import ffprobe
|
||||||
from .Utils import StreamingError, calc_download_path
|
from .Utils import StreamingError
|
||||||
|
|
||||||
|
|
||||||
class M3u8:
|
class M3u8:
|
||||||
|
|
@ -12,24 +12,21 @@ class M3u8:
|
||||||
ok_vcodecs: tuple = ("h264", "x264", "avc")
|
ok_vcodecs: tuple = ("h264", "x264", "avc")
|
||||||
ok_acodecs: tuple = ("aac", "m4a", "mp3")
|
ok_acodecs: tuple = ("aac", "m4a", "mp3")
|
||||||
|
|
||||||
def __init__(self, url: str, segment_duration: float | None = None):
|
def __init__(self, download_path: str, url: str, segment_duration: float | None = None):
|
||||||
self.url = url
|
self.url = url
|
||||||
|
self.download_path = download_path
|
||||||
self.duration = float(segment_duration) if segment_duration is not None else self.duration
|
self.duration = float(segment_duration) if segment_duration is not None else self.duration
|
||||||
|
|
||||||
async def make_stream(self, download_path: str, file: str) -> str:
|
async def make_stream(self, file: Path) -> str:
|
||||||
realFile: str = calc_download_path(base_path=download_path, folder=file, create_path=False)
|
|
||||||
|
|
||||||
if not os.path.exists(realFile):
|
|
||||||
error = f"File '{realFile}' does not exist."
|
|
||||||
raise StreamingError(error)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ff = await ffprobe(realFile)
|
ff = await ffprobe(file)
|
||||||
except UnicodeDecodeError:
|
except UnicodeDecodeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
file = str(file).replace(self.download_path, "").strip("/")
|
||||||
|
|
||||||
if "duration" not in ff.metadata:
|
if "duration" not in ff.metadata:
|
||||||
error = f"Unable to get '{realFile}' play duration."
|
error = f"Unable to get '{file}' play duration."
|
||||||
raise StreamingError(error)
|
raise StreamingError(error)
|
||||||
|
|
||||||
duration: float = float(ff.metadata.get("duration"))
|
duration: float = float(ff.metadata.get("duration"))
|
||||||
|
|
@ -70,13 +67,7 @@ class M3u8:
|
||||||
|
|
||||||
return "\n".join(m3u8)
|
return "\n".join(m3u8)
|
||||||
|
|
||||||
async def make_subtitle(self, download_path: str, file: str, duration: float) -> str:
|
async def make_subtitle(self, file: Path, duration: float) -> str:
|
||||||
realFile: str = calc_download_path(base_path=download_path, folder=file, create_path=False)
|
|
||||||
|
|
||||||
if not os.path.exists(realFile):
|
|
||||||
error = f"File '{realFile}' does not exist."
|
|
||||||
raise StreamingError(error)
|
|
||||||
|
|
||||||
m3u8 = []
|
m3u8 = []
|
||||||
|
|
||||||
m3u8.append("#EXTM3U")
|
m3u8.append("#EXTM3U")
|
||||||
|
|
@ -85,7 +76,7 @@ class M3u8:
|
||||||
m3u8.append("#EXT-X-MEDIA-SEQUENCE:0")
|
m3u8.append("#EXT-X-MEDIA-SEQUENCE:0")
|
||||||
m3u8.append("#EXT-X-PLAYLIST-TYPE:VOD")
|
m3u8.append("#EXT-X-PLAYLIST-TYPE:VOD")
|
||||||
m3u8.append(f"#EXTINF:{duration},")
|
m3u8.append(f"#EXTINF:{duration},")
|
||||||
m3u8.append(f"{self.url}api/player/subtitle/{quote(file)}.vtt")
|
m3u8.append(f"{self.url}api/player/subtitle/{quote(str(file).replace(self.download_path, '').strip('/'))}.vtt")
|
||||||
m3u8.append("#EXT-X-ENDLIST")
|
m3u8.append("#EXT-X-ENDLIST")
|
||||||
|
|
||||||
return "\n".join(m3u8)
|
return "\n".join(m3u8)
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,27 @@
|
||||||
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 .ffprobe import ffprobe
|
from .ffprobe import ffprobe
|
||||||
from .Utils import StreamingError, calc_download_path, check_id, get_sidecar_subtitles
|
from .Utils import StreamingError, get_sidecar_subtitles
|
||||||
|
|
||||||
|
|
||||||
class Playlist:
|
class Playlist:
|
||||||
_url: str = None
|
_url: str = None
|
||||||
|
|
||||||
def __init__(self, url: str):
|
def __init__(self, download_path: str, url: str):
|
||||||
self.url = url
|
self.url = url
|
||||||
|
self.download_path = download_path
|
||||||
|
|
||||||
async def make(self, download_path: str, file: str) -> str | Response:
|
async def make(self, file: Path) -> str:
|
||||||
rFile = Path(calc_download_path(base_path=download_path, folder=file, create_path=False))
|
ref = str(file).replace(self.download_path, "").strip("/")
|
||||||
|
|
||||||
if not rFile.exists():
|
|
||||||
possibleFile = check_id(file=rFile)
|
|
||||||
if not possibleFile:
|
|
||||||
msg = f"File '{rFile}' does not exist."
|
|
||||||
raise StreamingError(msg)
|
|
||||||
|
|
||||||
return Response(
|
|
||||||
status=HTTPFound.status_code,
|
|
||||||
headers={
|
|
||||||
"Location": f"{self.url}api/player/playlist/{quote(str(possibleFile).replace(download_path, '').strip('/'))}.m3u8"
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ff = await ffprobe(rFile)
|
ff = await ffprobe(file)
|
||||||
except UnicodeDecodeError:
|
except UnicodeDecodeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if "duration" not in ff.metadata:
|
if "duration" not in ff.metadata:
|
||||||
msg = f"Unable to get '{rFile}' duration."
|
msg = f"Unable to get '{ref}' duration."
|
||||||
raise StreamingError(msg)
|
raise StreamingError(msg)
|
||||||
|
|
||||||
playlist = []
|
playlist = []
|
||||||
|
|
@ -44,18 +30,18 @@ class Playlist:
|
||||||
subs = ""
|
subs = ""
|
||||||
|
|
||||||
duration: float = float(ff.metadata.get("duration"))
|
duration: float = float(ff.metadata.get("duration"))
|
||||||
for sub_file in get_sidecar_subtitles(rFile):
|
for sub_file in get_sidecar_subtitles(file):
|
||||||
lang = sub_file["lang"]
|
lang = sub_file["lang"]
|
||||||
item = sub_file["file"]
|
item = sub_file["file"]
|
||||||
name = sub_file["name"]
|
name = sub_file["name"]
|
||||||
|
|
||||||
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(ref).with_name(item.name)))}.m3u8?duration={duration}"
|
||||||
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}"'
|
||||||
)
|
)
|
||||||
|
|
||||||
playlist.append(f"#EXT-X-STREAM-INF:PROGRAM-ID=1{subs}")
|
playlist.append(f"#EXT-X-STREAM-INF:PROGRAM-ID=1{subs}")
|
||||||
playlist.append(f"{self.url}api/player/m3u8/video/{quote(file)}.m3u8")
|
playlist.append(f"{self.url}api/player/m3u8/video/{quote(ref)}.m3u8")
|
||||||
|
|
||||||
return "\n".join(playlist)
|
return "\n".join(playlist)
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,19 @@ import hashlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .ffprobe import ffprobe
|
from .ffprobe import ffprobe
|
||||||
from .Utils import StreamingError, calc_download_path
|
from .Utils import StreamingError
|
||||||
|
|
||||||
LOG = logging.getLogger("player.segments")
|
LOG = logging.getLogger("player.segments")
|
||||||
|
|
||||||
|
|
||||||
class Segments:
|
class Segments:
|
||||||
def __init__(self, index: int, duration: float, vconvert: bool, aconvert: bool):
|
def __init__(self, download_path: str, index: int, duration: float, vconvert: bool, aconvert: bool):
|
||||||
config = Config.get_instance()
|
config = Config.get_instance()
|
||||||
|
self.download_path = download_path
|
||||||
self.index = int(index)
|
self.index = int(index)
|
||||||
self.duration = float(duration)
|
self.duration = float(duration)
|
||||||
self.vconvert = bool(vconvert)
|
self.vconvert = bool(vconvert)
|
||||||
|
|
@ -24,23 +26,17 @@ class Segments:
|
||||||
self.vconvert = True
|
self.vconvert = True
|
||||||
self.aconvert = True
|
self.aconvert = True
|
||||||
|
|
||||||
async def stream(self, path: str, file: str) -> bytes:
|
async def stream(self, file: Path) -> bytes:
|
||||||
realFile: str = calc_download_path(base_path=path, folder=file, create_path=False)
|
|
||||||
|
|
||||||
if not os.path.exists(realFile):
|
|
||||||
msg = f"File {realFile} does not exist."
|
|
||||||
raise StreamingError(msg)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ff = await ffprobe(realFile)
|
ff = await ffprobe(file)
|
||||||
except UnicodeDecodeError:
|
except UnicodeDecodeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
tmpDir: str = tempfile.gettempdir()
|
tmpDir: str = tempfile.gettempdir()
|
||||||
tmpFile = os.path.join(tmpDir, f'ytptube_stream.{hashlib.sha256(realFile.encode("utf-8")).hexdigest()}')
|
tmpFile = os.path.join(tmpDir, f'ytptube_stream.{hashlib.sha256(str(file).encode("utf-8")).hexdigest()}')
|
||||||
|
|
||||||
if not os.path.exists(tmpFile):
|
if not os.path.exists(tmpFile):
|
||||||
os.symlink(realFile, tmpFile)
|
os.symlink(file, tmpFile)
|
||||||
|
|
||||||
if self.index == 0:
|
if self.index == 0:
|
||||||
startTime: float = f"{0:.6f}"
|
startTime: float = f"{0:.6f}"
|
||||||
|
|
@ -95,7 +91,7 @@ class Segments:
|
||||||
fargs.append("mpegts")
|
fargs.append("mpegts")
|
||||||
fargs.append("pipe:1")
|
fargs.append("pipe:1")
|
||||||
|
|
||||||
LOG.debug(f"Streaming '{realFile}' segment '{self.index}'. ffmpeg: {' '.join(fargs)}")
|
LOG.debug(f"Streaming '{file}' segment '{self.index}'. ffmpeg: {' '.join(fargs)}")
|
||||||
|
|
||||||
proc = await asyncio.subprocess.create_subprocess_exec(
|
proc = await asyncio.subprocess.create_subprocess_exec(
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
|
|
@ -108,8 +104,8 @@ class Segments:
|
||||||
data, err = await proc.communicate()
|
data, err = await proc.communicate()
|
||||||
|
|
||||||
if 0 != proc.returncode:
|
if 0 != proc.returncode:
|
||||||
LOG.error(f"Failed to stream '{realFile}' segment '{self.index}'. {err.decode('utf-8')}.")
|
LOG.error(f"Failed to stream '{file}' segment '{self.index}'. {err.decode('utf-8')}.")
|
||||||
msg = f"Failed to stream '{realFile}' segment '{self.index}'."
|
msg = f"Failed to stream '{file}' segment '{self.index}'."
|
||||||
raise StreamingError(msg)
|
raise StreamingError(msg)
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import logging
|
import logging
|
||||||
import pathlib
|
from pathlib import Path
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
import pysubs2
|
import pysubs2
|
||||||
from pysubs2.formats.substation import SubstationFormat
|
from pysubs2.formats.substation import SubstationFormat
|
||||||
from pysubs2.time import ms_to_times
|
from pysubs2.time import ms_to_times
|
||||||
|
|
||||||
from .Utils import calc_download_path
|
from .Utils import ALLOWED_SUBS_EXTENSIONS
|
||||||
|
|
||||||
LOG = logging.getLogger("player.subtitle")
|
LOG = logging.getLogger("player.subtitle")
|
||||||
|
|
||||||
|
|
@ -22,33 +22,22 @@ SubstationFormat.ms_to_timestamp = ms_to_timestamp
|
||||||
|
|
||||||
|
|
||||||
class Subtitle:
|
class Subtitle:
|
||||||
allowed_extensions: tuple[str] = (
|
def __init__(self, download_path: str):
|
||||||
".srt",
|
self.download_path = download_path
|
||||||
".vtt",
|
|
||||||
".ass",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def make(self, path: str, file: str) -> str:
|
async def make(self, file: Path) -> str:
|
||||||
realFile: str = calc_download_path(base_path=path, folder=file, create_path=False)
|
if file.suffix not in ALLOWED_SUBS_EXTENSIONS:
|
||||||
|
|
||||||
rFile = pathlib.Path(realFile)
|
|
||||||
|
|
||||||
if not rFile.exists():
|
|
||||||
msg = f"File '{file}' does not exist."
|
|
||||||
raise Exception(msg)
|
|
||||||
|
|
||||||
if rFile.suffix not in self.allowed_extensions:
|
|
||||||
msg = f"File '{file}' subtitle type is not supported."
|
msg = f"File '{file}' subtitle type is not supported."
|
||||||
raise Exception(msg)
|
raise Exception(msg)
|
||||||
|
|
||||||
if rFile.suffix == ".vtt":
|
if file.suffix == ".vtt":
|
||||||
async with await anyio.open_file(realFile) as f:
|
async with await anyio.open_file(file) as f:
|
||||||
return await f.read()
|
return await f.read()
|
||||||
|
|
||||||
subs = pysubs2.load(path=str(rFile))
|
subs = pysubs2.load(path=str(file))
|
||||||
|
|
||||||
if len(subs.events) < 1:
|
if len(subs.events) < 1:
|
||||||
msg = f"No subtitle events were found in '{rFile}'."
|
msg = f"No subtitle events were found in '{file}'."
|
||||||
raise Exception(msg)
|
raise Exception(msg)
|
||||||
|
|
||||||
if len(subs.events) < 2:
|
if len(subs.events) < 2:
|
||||||
|
|
|
||||||
|
|
@ -19,13 +19,7 @@ from .LogWrapper import LogWrapper
|
||||||
|
|
||||||
LOG = logging.getLogger("Utils")
|
LOG = logging.getLogger("Utils")
|
||||||
|
|
||||||
IGNORED_KEYS: tuple[str] = (
|
IGNORED_KEYS: tuple[str] = ("paths", "outtmpl", "progress_hooks", "postprocessor_hooks", "download_archive")
|
||||||
"paths",
|
|
||||||
"outtmpl",
|
|
||||||
"progress_hooks",
|
|
||||||
"postprocessor_hooks",
|
|
||||||
"download_archive",
|
|
||||||
)
|
|
||||||
YTDLP_INFO_CLS: yt_dlp.YoutubeDL = None
|
YTDLP_INFO_CLS: yt_dlp.YoutubeDL = None
|
||||||
|
|
||||||
ALLOWED_SUBS_EXTENSIONS: tuple[str] = (".srt", ".vtt", ".ass")
|
ALLOWED_SUBS_EXTENSIONS: tuple[str] = (".srt", ".vtt", ".ass")
|
||||||
|
|
@ -365,7 +359,7 @@ def check_id(file: pathlib.Path) -> bool | str:
|
||||||
if id not in f.stem:
|
if id not in f.stem:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if f.suffix not in (".mp4", ".mkv", ".webm", ".m4v", ".m4a", ".mp3", ".aac", ".ogg"):
|
if f.suffix != file.suffix:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return f.absolute()
|
return f.absolute()
|
||||||
|
|
@ -599,3 +593,31 @@ def get_mime_type(metadata: dict, file_path: pathlib.Path) -> str:
|
||||||
|
|
||||||
# Final fallback: Return generic binary type
|
# Final fallback: Return generic binary type
|
||||||
return "application/octet-stream"
|
return "application/octet-stream"
|
||||||
|
|
||||||
|
|
||||||
|
def get_file(download_path: str, file: str | pathlib.Path) -> tuple[pathlib.Path, int]:
|
||||||
|
"""
|
||||||
|
Get the real file path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
download_path (str): Base download path.
|
||||||
|
file (str|pathlib.Path): File path.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
pathlib.Path: Real file path.
|
||||||
|
int: http status code.
|
||||||
|
|
||||||
|
"""
|
||||||
|
file_path: str = os.path.normpath(os.path.join(download_path, str(file)))
|
||||||
|
if not file_path.startswith(download_path):
|
||||||
|
return (pathlib.Path(file_path), 404)
|
||||||
|
|
||||||
|
realFile: str = pathlib.Path(calc_download_path(base_path=str(download_path), folder=str(file), create_path=False))
|
||||||
|
if realFile.exists():
|
||||||
|
return (realFile, 200)
|
||||||
|
|
||||||
|
possibleFile = check_id(file=realFile)
|
||||||
|
if not possibleFile:
|
||||||
|
return (realFile, 404)
|
||||||
|
|
||||||
|
return (pathlib.Path(possibleFile), 302)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue