416 lines
15 KiB
Python
416 lines
15 KiB
Python
import time
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
|
|
from aiohttp import web
|
|
from aiohttp.web import Request, Response
|
|
|
|
from app.features.streaming.library.m3u8 import M3u8
|
|
from app.features.streaming.library.playlist import Playlist
|
|
from app.features.streaming.library.segments import Segments
|
|
from app.features.streaming.library.subtitle import Subtitle, get_subtitle_tracks
|
|
from app.features.streaming.types import StreamingError
|
|
from app.library.config import Config
|
|
from app.library.log import get_logger
|
|
from app.library.router import route
|
|
from app.library.Utils import get_file
|
|
|
|
LOG = get_logger()
|
|
|
|
|
|
@route("GET", "api/player/playlist/{file:.*}.m3u8", "playlist_create")
|
|
async def playlist_create(request: Request, config: Config, app: web.Application) -> Response:
|
|
"""
|
|
Get the playlist.
|
|
|
|
Args:
|
|
request (Request): The request object.
|
|
config (Config): The configuration instance.
|
|
app (web.Application): The aiohttp application instance.
|
|
|
|
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)
|
|
|
|
base_path: str = config.base_path.rstrip("/")
|
|
|
|
try:
|
|
realFile, status = get_file(download_path=config.download_path, file=file)
|
|
if web.HTTPFound.status_code == status:
|
|
return Response(
|
|
status=web.HTTPFound.status_code,
|
|
headers={
|
|
"Location": str(
|
|
app.router["playlist_create"].url_for(
|
|
file=str(realFile).replace(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.Response(
|
|
text=await Playlist(download_path=Path(config.download_path), url=f"{base_path}/").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:
|
|
return web.json_response(data={"error": str(e)}, status=web.HTTPNotFound.status_code)
|
|
|
|
|
|
@route("GET", "api/player/m3u8/{mode}/{file:.*}.m3u8", "m3u8_create")
|
|
async def m3u8_create(request: Request, config: Config, app: web.Application) -> Response:
|
|
"""
|
|
Get the m3u8 file.
|
|
|
|
Args:
|
|
request (Request): The request object.
|
|
config (Config): The configuration instance.
|
|
app (web.Application): The aiohttp application instance.
|
|
|
|
Returns:
|
|
Response: The response object.
|
|
|
|
"""
|
|
file: str = request.match_info.get("file")
|
|
mode: str = request.match_info.get("mode")
|
|
|
|
if mode not in ["video", "subtitle"]:
|
|
return web.json_response(
|
|
data={"error": "Only video and subtitle modes are supported."}, status=web.HTTPBadRequest.status_code
|
|
)
|
|
|
|
if not file:
|
|
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
|
|
|
|
duration = request.query.get("duration", None)
|
|
|
|
if "subtitle" in mode:
|
|
if not duration:
|
|
return web.json_response(data={"error": "duration is required."}, status=web.HTTPBadRequest.status_code)
|
|
|
|
duration = float(duration)
|
|
|
|
base_path: str = config.base_path.rstrip("/")
|
|
|
|
try:
|
|
cls = M3u8(download_path=Path(config.download_path), url=f"{base_path}/")
|
|
|
|
realFile, status = get_file(download_path=config.download_path, file=file)
|
|
if web.HTTPFound.status_code == status:
|
|
return Response(
|
|
status=status,
|
|
headers={
|
|
"Location": str(
|
|
app.router["m3u8_create"].url_for(
|
|
mode=mode, file=str(realFile).replace(config.download_path, "").strip("/")
|
|
)
|
|
),
|
|
},
|
|
)
|
|
|
|
if web.HTTPNotFound.status_code == status:
|
|
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
|
|
|
|
if "subtitle" in mode:
|
|
text = await cls.make_subtitle(file=realFile, duration=duration)
|
|
else:
|
|
text = await cls.make_stream(file=realFile)
|
|
except StreamingError as e:
|
|
LOG.exception(
|
|
"Failed to create %s streaming playlist for '%s': %s.",
|
|
mode,
|
|
file,
|
|
e,
|
|
extra={"route": "streaming.playlist", "file_path": file, "mode": mode, "exception_type": type(e).__name__},
|
|
)
|
|
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", r"api/player/segments/{segment:\d+}/{file:.*}.ts", "segments_stream")
|
|
async def segments_stream(request: Request, config: Config, app: web.Application) -> Response:
|
|
"""
|
|
Get the segments.
|
|
|
|
Args:
|
|
request (Request): The request object.
|
|
config (Config): The configuration instance.
|
|
app (web.Application): The aiohttp application instance.
|
|
|
|
Returns:
|
|
Response: The response object.
|
|
|
|
"""
|
|
file: str = request.match_info.get("file")
|
|
segment: int = request.match_info.get("segment")
|
|
sd: int = request.query.get("sd")
|
|
vc: int = int(request.query.get("vc", 0))
|
|
ac: int = int(request.query.get("ac", 0))
|
|
|
|
if not file:
|
|
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
|
|
|
|
if not segment:
|
|
return web.json_response(data={"error": "segment id is required."}, status=web.HTTPBadRequest.status_code)
|
|
|
|
realFile, status = get_file(download_path=config.download_path, file=file)
|
|
if web.HTTPFound.status_code == status:
|
|
return Response(
|
|
status=status,
|
|
headers={
|
|
"Location": str(
|
|
app.router["segments_stream"].url_for(
|
|
segment=segment,
|
|
file=str(realFile).replace(config.download_path, "").strip("/"),
|
|
)
|
|
),
|
|
},
|
|
)
|
|
|
|
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})
|
|
|
|
resp = web.StreamResponse(
|
|
status=web.HTTPOk.status_code,
|
|
headers={
|
|
"Content-Type": "video/mpegts",
|
|
"X-Accel-Buffering": "no",
|
|
"Access-Control-Allow-Origin": "*",
|
|
"Pragma": "public",
|
|
"Cache-Control": f"public, max-age={time.time() + 31536000}",
|
|
"Last-Modified": time.strftime(
|
|
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple()
|
|
),
|
|
"Expires": time.strftime(
|
|
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(time.time() + 31536000, tz=UTC).timetuple()
|
|
),
|
|
},
|
|
)
|
|
|
|
await resp.prepare(request)
|
|
|
|
await Segments(
|
|
download_path=config.download_path,
|
|
index=int(segment),
|
|
duration=float(f"{float(sd or M3u8.duration):.6f}"),
|
|
vconvert=vc == 1,
|
|
aconvert=ac == 1,
|
|
).stream(realFile, resp)
|
|
|
|
return resp
|
|
|
|
|
|
@route("GET", "api/player/subtitle/{file:.*}.vtt", "subtitles_get")
|
|
async def subtitles_get(request: Request, config: Config, app: web.Application) -> Response:
|
|
"""
|
|
Get the subtitles.
|
|
|
|
Args:
|
|
request (Request): The request object.
|
|
config (Config): The configuration instance.
|
|
app (web.Application): The aiohttp application instance.
|
|
|
|
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)
|
|
|
|
realFile, status = get_file(download_path=config.download_path, file=file)
|
|
if web.HTTPFound.status_code == status:
|
|
return Response(
|
|
status=status,
|
|
headers={
|
|
"Location": str(
|
|
app.router["subtitles_get"].url_for(file=str(realFile).replace(config.download_path, "").strip("/"))
|
|
),
|
|
},
|
|
)
|
|
|
|
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(
|
|
body=await Subtitle().make(file=realFile),
|
|
headers={
|
|
"Content-Type": "text/vtt; charset=UTF-8",
|
|
"X-Accel-Buffering": "no",
|
|
"Access-Control-Allow-Origin": "*",
|
|
"Pragma": "public",
|
|
"Cache-Control": f"public, max-age={time.time() + 31536000}",
|
|
"Last-Modified": time.strftime(
|
|
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple()
|
|
),
|
|
"Expires": time.strftime(
|
|
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(time.time() + 31536000, tz=UTC).timetuple()
|
|
),
|
|
},
|
|
status=web.HTTPOk.status_code,
|
|
)
|
|
|
|
|
|
@route("GET", "api/player/subtitles/manifest/{file:.*}", "subtitles_manifest_get")
|
|
async def subtitles_manifest_get(request: Request, config: Config, app: web.Application) -> Response:
|
|
"""
|
|
Get subtitle track metadata for a media file.
|
|
|
|
Args:
|
|
request (Request): The request object.
|
|
config (Config): The configuration instance.
|
|
app (web.Application): The aiohttp application instance.
|
|
|
|
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)
|
|
|
|
realFile, status = get_file(download_path=config.download_path, file=file)
|
|
if web.HTTPFound.status_code == status:
|
|
return Response(
|
|
status=status,
|
|
headers={
|
|
"Location": str(
|
|
app.router["subtitles_manifest_get"].url_for(
|
|
file=str(realFile).replace(config.download_path, "").strip("/")
|
|
)
|
|
),
|
|
},
|
|
)
|
|
|
|
if web.HTTPNotFound.status_code == status:
|
|
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
|
|
|
|
tracks = [
|
|
{
|
|
"lang": track.lang,
|
|
"name": track.name,
|
|
"source_format": track.source_format,
|
|
"delivery_format": track.delivery_format,
|
|
"renderer": track.renderer,
|
|
"url": str(
|
|
app.router["subtitles_track_get"].url_for(
|
|
source_format=track.source_format,
|
|
file=str(track.file).replace(config.download_path, "").strip("/"),
|
|
)
|
|
),
|
|
}
|
|
for track in get_subtitle_tracks(realFile)
|
|
]
|
|
|
|
return web.json_response(data={"subtitles": tracks}, status=web.HTTPOk.status_code)
|
|
|
|
|
|
@route("GET", "api/player/subtitles/{source_format}/{file:.*}", "subtitles_track_get")
|
|
async def subtitles_track_get(request: Request, config: Config, app: web.Application) -> Response:
|
|
"""
|
|
Get a subtitle file using its preferred delivery format.
|
|
|
|
Args:
|
|
request (Request): The request object.
|
|
config (Config): The configuration instance.
|
|
app (web.Application): The aiohttp application instance.
|
|
|
|
Returns:
|
|
Response: The response object.
|
|
|
|
"""
|
|
file: str = request.match_info.get("file")
|
|
source_format: str | None = request.match_info.get("source_format")
|
|
|
|
if not file:
|
|
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
|
|
|
|
fmt = Subtitle.normalize_format(source_format or "")
|
|
if fmt is None:
|
|
return web.json_response(
|
|
data={"error": "Only vtt, srt, and ass subtitle formats are supported."},
|
|
status=web.HTTPBadRequest.status_code,
|
|
)
|
|
|
|
realFile, status = get_file(download_path=config.download_path, file=file)
|
|
if web.HTTPFound.status_code == status:
|
|
return Response(
|
|
status=status,
|
|
headers={
|
|
"Location": str(
|
|
app.router["subtitles_track_get"].url_for(
|
|
source_format=fmt,
|
|
file=str(realFile).replace(config.download_path, "").strip("/"),
|
|
)
|
|
),
|
|
},
|
|
)
|
|
|
|
if web.HTTPNotFound.status_code == status:
|
|
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
|
|
|
|
if Subtitle.normalize_format(realFile.suffix) != fmt:
|
|
return web.json_response(
|
|
data={"error": f"Subtitle file '{file}' does not match requested source format '{fmt}'."},
|
|
status=web.HTTPBadRequest.status_code,
|
|
)
|
|
|
|
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})
|
|
|
|
body, content_type = await Subtitle().make_delivery(file=realFile)
|
|
return web.Response(
|
|
body=body,
|
|
headers={
|
|
"Content-Type": content_type,
|
|
"X-Accel-Buffering": "no",
|
|
"Access-Control-Allow-Origin": "*",
|
|
"Pragma": "public",
|
|
"Cache-Control": f"public, max-age={time.time() + 31536000}",
|
|
"Last-Modified": time.strftime(
|
|
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple()
|
|
),
|
|
"Expires": time.strftime(
|
|
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(time.time() + 31536000, tz=UTC).timetuple()
|
|
),
|
|
},
|
|
status=web.HTTPOk.status_code,
|
|
)
|