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, )