import base64
|
|
from typing import cast
|
|
import urllib.parse
|
|
from xml.sax import saxutils
|
|
|
|
import httpx
|
|
from starlette.applications import Starlette
|
|
from starlette.requests import Request
|
|
from starlette.responses import JSONResponse, Response, StreamingResponse
|
|
|
|
from dlhdhr import config
|
|
from dlhdhr.dlhd import DLHDClient
|
|
from dlhdhr.tuner import TunerManager, TunerNotFoundError
|
|
from dlhdhr.epg import EPG
|
|
|
|
|
|
def get_public_url(request: Request, path: str) -> str:
|
|
return urllib.parse.urljoin(str(request.url), path)
|
|
|
|
|
|
async def channel_playlist_m3u8(request: Request) -> Response:
|
|
channel_number: str = str(request.path_params["channel_number"])
|
|
|
|
dlhd = cast(DLHDClient, request.app.state.dlhd)
|
|
channel = dlhd.get_channel(channel_number)
|
|
if not channel:
|
|
return Response("", status_code=404)
|
|
|
|
playlist = await dlhd.get_channel_playlist(channel)
|
|
return Response(content=playlist.dumps(), status_code=200, media_type="application/vnd.apple.mpegurl")
|
|
|
|
|
|
async def channel_segment_ts(request: Request) -> Response:
|
|
channel_number: str = str(request.path_params["channel_number"])
|
|
segment_path: str = f"{request.path_params['segment_path']}.ts"
|
|
|
|
dlhd = cast(DLHDClient, request.app.state.dlhd)
|
|
|
|
dlhd = cast(DLHDClient, request.app.state.dlhd)
|
|
channel = dlhd.get_channel(channel_number)
|
|
if not channel:
|
|
return Response("", status_code=404)
|
|
|
|
return StreamingResponse(
|
|
dlhd.stream_segment(channel, segment_path),
|
|
status_code=200,
|
|
media_type="video/mp2t",
|
|
headers={
|
|
"Access-Control-Allow-Origin": "*",
|
|
"Content-Disposition": "inline",
|
|
"Content-Transfer-Enconding": "binary",
|
|
},
|
|
)
|
|
|
|
|
|
async def channel_proxy(request: Request) -> Response:
|
|
channel_number: str = str(request.path_params["channel_number"])
|
|
|
|
dlhd = cast(DLHDClient, request.app.state.dlhd)
|
|
|
|
channel = dlhd.get_channel(channel_number)
|
|
if not channel:
|
|
return Response("", status_code=404)
|
|
|
|
tuners = cast(TunerManager, request.app.state.tuners)
|
|
|
|
try:
|
|
tuner = tuners.claim_tuner(channel)
|
|
except TunerNotFoundError:
|
|
return Response("", status_code=404)
|
|
|
|
listener = await tuner.get_listener()
|
|
|
|
async def _generator():
|
|
async for chunk in listener:
|
|
yield chunk
|
|
|
|
return StreamingResponse(
|
|
_generator(),
|
|
status_code=200,
|
|
media_type="video/mp2t",
|
|
headers={
|
|
"Access-Control-Allow-Origin": "*",
|
|
"Content-Disposition": "inline",
|
|
"Content-Transfer-Enconding": "binary",
|
|
},
|
|
)
|
|
|
|
|
|
async def listings_json(request: Request) -> JSONResponse:
|
|
dlhd = cast(DLHDClient, request.app.state.dlhd)
|
|
channels = sorted(dlhd.get_channels(), key=lambda c: int(c.number))
|
|
|
|
return JSONResponse(
|
|
[
|
|
{
|
|
"GuideName": channel.name,
|
|
"GuideNumber": channel.number,
|
|
"URL": get_public_url(request, channel.channel_proxy),
|
|
}
|
|
for channel in channels
|
|
]
|
|
)
|
|
|
|
|
|
async def discover_json(request: Request) -> JSONResponse:
|
|
tuners = cast(TunerManager, request.app.state.tuners)
|
|
|
|
return JSONResponse(
|
|
{
|
|
"FriendlyName": config.DLHD_FRIENDLY_NAME,
|
|
"Manufacturer": "dlhdhomerun",
|
|
"ManufacturerURL": "https://c653labs.com/",
|
|
"ModelNumber": "HDTC-2US",
|
|
"FirmwareName": "hdhomeruntc_atsc",
|
|
"TunerCount": tuners.total_available_listeners,
|
|
"FirmwareVersion": "20170930",
|
|
"DeviceID": config.DLHD_DEVICE_ID,
|
|
"DeviceAuth": "",
|
|
"BaseURL": get_public_url(request, "/"),
|
|
"LineupURL": get_public_url(request, "/lineup.json"),
|
|
}
|
|
)
|
|
|
|
|
|
async def lineup_status_json(_: Request) -> JSONResponse:
|
|
return JSONResponse(
|
|
{
|
|
"ScanInProgress": 0,
|
|
"ScanPossible": 1,
|
|
"Source": "Cable",
|
|
"SourceList": ["Cable"],
|
|
}
|
|
)
|
|
|
|
|
|
async def xmltv_xml(request: Request) -> Response:
|
|
dlhd = cast(DLHDClient, request.app.state.dlhd)
|
|
epg = cast(EPG, request.app.state.epg)
|
|
|
|
dlhd_channels = dlhd.get_channels()
|
|
return Response(await epg.generate_xmltv(dlhd_channels), media_type="application/xml; charset=utf-8")
|
|
|
|
|
|
async def iptv_m3u(request: Request) -> Response:
|
|
dlhd = cast(DLHDClient, request.app.state.dlhd)
|
|
dlhd_channels = dlhd.get_channels()
|
|
|
|
output = "#EXTM3U\n"
|
|
for channel in dlhd_channels:
|
|
if not channel.xmltv_id:
|
|
continue
|
|
output += f'#EXTINF:-1 CUID="{channel.number}" tvg-id="{channel.xmltv_id}" tvg-chno="{channel.number}" channel-id="{channel.number}",{channel.name}\n'
|
|
output += get_public_url(request, channel.channel_proxy)
|
|
output += "\n"
|
|
|
|
return Response(output, media_type="text/plain")
|
|
|
|
|
|
async def channel_key_proxy(request: Request) -> Response:
|
|
channel_number: str = str(request.path_params["channel_number"])
|
|
proxy_url: bytes = base64.urlsafe_b64decode(request.path_params["proxy_url"])
|
|
|
|
dlhd = cast(DLHDClient, request.app.state.dlhd)
|
|
channel = dlhd.get_channel(channel_number)
|
|
if not channel:
|
|
return Response("", status_code=404)
|
|
|
|
key = await dlhd.get_channel_key(channel, proxy_url.decode())
|
|
|
|
return Response(key, status_code=200, media_type="application/octet-stream")
|
|
|
|
|
|
def create_app() -> Starlette:
|
|
dlhd_client = DLHDClient()
|
|
tuner_manager = TunerManager()
|
|
|
|
app = Starlette()
|
|
app.state.dlhd = dlhd_client
|
|
app.state.tuners = tuner_manager
|
|
app.state.epg = EPG()
|
|
app.add_route("/discover.json", discover_json)
|
|
app.add_route("/lineup_status.json", lineup_status_json)
|
|
app.add_route("/listings.json", listings_json)
|
|
app.add_route("/lineup.json", listings_json)
|
|
app.add_route("/xmltv.xml", xmltv_xml)
|
|
app.add_route("/iptv.m3u", iptv_m3u)
|
|
app.add_route("/channel/{channel_number:int}/playlist.m3u8", channel_playlist_m3u8)
|
|
app.add_route("/channel/{channel_number:int}/{segment_path:path}.ts", channel_segment_ts)
|
|
app.add_route("/channel/{channel_number:int}/key/{proxy_url:str}", channel_key_proxy)
|
|
app.add_route("/channel/{channel_number:int}", channel_proxy)
|
|
|
|
return app
|