Browse Source

initial prototype

main
Brett Langdon 2 years ago
commit
af11093fd7
No known key found for this signature in database GPG Key ID: 9BAD4322A65AD78B
13 changed files with 1221 additions and 0 deletions
  1. +13
    -0
      Dockerfile
  2. +9
    -0
      LICENSE.txt
  3. +82
    -0
      README.md
  4. +137
    -0
      pyproject.toml
  5. +4
    -0
      src/dlhdhr/__about__.py
  6. +3
    -0
      src/dlhdhr/__init__.py
  7. +13
    -0
      src/dlhdhr/__main__.py
  8. +210
    -0
      src/dlhdhr/app.py
  9. +24
    -0
      src/dlhdhr/config.py
  10. +142
    -0
      src/dlhdhr/dlhd.py
  11. +74
    -0
      src/dlhdhr/ffmpeg.py
  12. +180
    -0
      src/dlhdhr/tuner.py
  13. +330
    -0
      src/dlhdhr/tvg_id.py

+ 13
- 0
Dockerfile View File

@ -0,0 +1,13 @@
FROM python:3.12-alpine
RUN apk add --no-cache ffmpeg
ENV DLHDHR_HOST=0.0.0.0
ENV DLHDHR_PORT=8000
WORKDIR /app/
COPY . /app/
RUN pip install .
EXPOSE 8000
CMD ["python", "-m", "dlhdhr"]

+ 9
- 0
LICENSE.txt View File

@ -0,0 +1,9 @@
MIT License
Copyright (c) 2023-present Brett Langdon <me@brett.is>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

+ 82
- 0
README.md View File

@ -0,0 +1,82 @@
# DLHDHomeRun
[![PyPI - Version](https://img.shields.io/pypi/v/dlhdhr.svg)](https://pypi.org/project/dlhdhr
[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/dlhdhr.svg)](https://pypi.org/project/dlhdhr
-----
**Table of Contents**
- [Installation](#installation)
- [License](#license)
## Installation
### pip
```console
pip install dlhdhr
```
### docker
``` console
docker run --rm -p 8000:8000 brettlangdon/dlhdhr:latest
```
## Running
``` console
# Start the server
dlhdhr
```
## Configuration
All Configuration is done via environment variables.
``` console
DLHDHR_HOST="0.0.0.0" DLHDHR_PORT="8080" dlhdhr
docker run --rm -e "DLHDHR_PORT=8080" -p 8080:8080 brettlangdon/dlhdhr:latest
```
### Server
- `DLHDHR_HOST="<ip-address>"`
- Which ip address bind to. `dlhdhr` command default is "127.0.0.1", the docker container defaults to "0.0.0.0".
- `DLHDHR_PORT="<port>"`
- Which port the server should bind to. Default is "8000".
### Channel selection
By default `dlhdhr` will include all channels from DaddyLive, however you can select or exclude specific channels.
- `DLHDHR_CHANNEL_EXCLUDE="<cn>,<cn>,<cn>,...`
- Exclude the specified DaddyLive channel numbers.
- `DLHDHR_CHANNEL_ALLOW="<cn>,<cn>,<cn>,...`
- Include only the specified DaddyLive channel numbers.
### EPG
#### default
By default `dlhdhr` will generate an `xmltv.xml` with only the channel numbers and names.
#### epg.best
- `DLHDHR_EPG_PROVIDER="epg.best"`
- `DLHDHR_EPG_BEST_XMLTV_URL="https://epg.best/<filename>.m3u"`
## Endpoints
- `/discover.json`
- `/lineup_status.json`
- `/listings.json`
- `/lineup.json`
- `/xmltv.xml`
- `/iptv.m3u`
- `/channel/{channel_number:int}/playlist.m3u8`
- `/channel/{channel_number:int}/{segment_path:path}.ts`
- `/channel/{channel_number:int}`
## License
`dlhdhr` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.

+ 137
- 0
pyproject.toml View File

@ -0,0 +1,137 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "dlhdhr"
dynamic = ["version"]
description = ''
readme = "README.md"
requires-python = ">=3.11"
license = "MIT"
keywords = []
authors = [
{ name = "Brett Langdon", email = "me@brett.is" },
]
classifiers = [
"Development Status :: 4 - Beta",
"Programming Language :: Python",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
]
dependencies = [
"starlette~=0.34.0",
"httpx~=0.25.2",
"uvicorn~=0.24.0",
"uvloop~=0.19.0",
"httptools~=0.6.1",
"lxml~=4.9.3",
"cssselect~=1.2.0",
"m3u8~=4.0.0",
]
[project.urls]
Documentation = "https://github.com/unknown/dlhdhr#readme"
Issues = "https://github.com/unknown/dlhdhr/issues"
Source = "https://github.com/unknown/dlhdhr"
[project.scripts]
dlhdhr = "dlhdhr.__main__:main"
[tool.hatch.version]
path = "src/dlhdhr/__about__.py"
[tool.hatch.envs.default]
dependencies = [
"coverage[toml]>=6.5",
"pytest",
]
[[tool.hatch.envs.all.matrix]]
python = ["3.11", "3.12"]
[tool.hatch.envs.lint]
detached = true
dependencies = [
"black>=23.1.0",
"mypy>=1.0.0",
"ruff>=0.0.243",
]
[tool.hatch.envs.lint.scripts]
typing = "mypy --install-types --non-interactive {args:src/dlhdhr}"
style = [
"ruff {args:.}",
"black --check --diff {args:.}",
]
fmt = [
"black {args:.}",
"ruff --fix {args:.}",
"style",
]
all = [
"style",
"typing",
]
[tool.black]
target-version = ["py311"]
line-length = 120
skip-string-normalization = true
[tool.ruff]
target-version = "py311"
line-length = 120
select = [
"A",
"ARG",
"B",
"C",
"DTZ",
"E",
"EM",
"F",
"FBT",
"I",
"ICN",
"ISC",
"N",
"PLC",
"PLE",
"PLR",
"PLW",
"Q",
"RUF",
"S",
"T",
"TID",
"UP",
"W",
"YTT",
]
ignore = [
# Allow non-abstract empty methods in abstract base classes
"B027",
# Allow boolean positional values in function calls, like `dict.get(... True)`
"FBT003",
# Ignore checks for possible passwords
"S105", "S106", "S107",
# Ignore complexity
"C901", "PLR0911", "PLR0912", "PLR0913", "PLR0915",
]
unfixable = [
# Don't touch unused imports
"F401",
]
[tool.ruff.isort]
known-first-party = ["dlhdhr"]
[tool.ruff.flake8-tidy-imports]
ban-relative-imports = "all"
[tool.ruff.per-file-ignores]
# Tests can use magic values, assertions, and relative imports
"tests/**/*" = ["PLR2004", "S101", "TID252"]

+ 4
- 0
src/dlhdhr/__about__.py View File

@ -0,0 +1,4 @@
# SPDX-FileCopyrightText: 2023-present Brett Langdon <me@brett.is>
#
# SPDX-License-Identifier: MIT
__version__ = "0.0.1"

+ 3
- 0
src/dlhdhr/__init__.py View File

@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2023-present Brett Langdon <me@brett.is>
#
# SPDX-License-Identifier: MIT

+ 13
- 0
src/dlhdhr/__main__.py View File

@ -0,0 +1,13 @@
import uvicorn
from dlhdhr import config
from dlhdhr.app import create_app
def main() -> None:
app = create_app()
uvicorn.run(app, host=config.HOST, port=config.PORT)
if __name__ == "__main__":
main()

+ 210
- 0
src/dlhdhr/app.py View File

@ -0,0 +1,210 @@
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
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 = await 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 = await 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 = await 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(await 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": "dlhdhomerun",
"Manufacturer": "dlhdhomerun - Silicondust",
"ManufacturerURL": "https://c653labs.com/",
"ModelNumber": "HDTC-2US",
"FirmwareName": "hdhomeruntc_atsc",
"TunerCount": tuners.total_available_listeners,
"FirmwareVersion": "20170930",
"DeviceID": "dlhdhomerun",
"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:
if config.EPG_PROVIDER == "epg.best":
if not config.EPG_BEST_XMLTV_URL:
return Response("", status_code=404)
async def _generator():
if not config.EPG_BEST_XMLTV_URL:
return
async with httpx.AsyncClient() as client:
async with client.stream("GET", config.EPG_BEST_XMLTV_URL) as res:
async for chunk in res.aiter_bytes():
yield chunk
headers = {}
if config.EPG_BEST_XMLTV_URL.endswith(".gz"):
headers["Content-Encoding"] = "gzip"
return StreamingResponse(
_generator(),
status_code=200,
media_type="application/xml; charset=utf-8",
headers={
"Content-Encoding": "gzip",
},
)
dlhd = cast(DLHDClient, request.app.state.dlhd)
dlhd_channels = await dlhd.get_channels()
response = '<tv generator-info-name="dlhdhr">'
for channel in dlhd_channels:
name = saxutils.escape(channel.name)
response += f'<channel id="{channel.number}">'
response += f'<display-name lang="en">{name}</display-name>'
response += f"<lcn>{channel.number}</lcn>"
response += "</channel>"
response += "</tv>"
return Response(response, media_type="application/xml; charset=utf-8")
async def iptv_m3u(request: Request) -> Response:
dlhd = cast(DLHDClient, request.app.state.dlhd)
dlhd_channels = await dlhd.get_channels()
output = "#EXTM3U\n"
for channel in dlhd_channels:
if not channel.tvg_id:
continue
output += f'#EXTINF:-1 CUID="{channel.number}" tvg-id="{channel.tvg_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")
def create_app() -> Starlette:
dlhd_client = DLHDClient()
tuner_manager = TunerManager()
app = Starlette()
app.state.dlhd = dlhd_client
app.state.tuners = tuner_manager
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}", channel_proxy)
return app

+ 24
- 0
src/dlhdhr/config.py View File

@ -0,0 +1,24 @@
import os
def _set_or_none(name: str) -> set[str] | None:
env = os.getenv(name)
if not env:
return None
return set(v.strip() for v in env.split(",") if v.strip())
HOST = os.getenv("DLHDHR_HOST", "127.0.0.1")
PORT: int = int(os.getenv("DLHDHR_PORT", 8000))
DLHD_BASE_URL = os.getenv("DLHD_BASE_URL", "https://dlhd.sx/")
DLHD_INDEX_M3U8_PATTERN = os.getenv(
"DLHD_INDEX_M3U8_PATTERN", "https://webudit.webhd.ru/lb/premium{channel.number}/index.m3u8"
)
CHANNEL_EXCLUDE: set[str] | None = _set_or_none("DLHDHR_CHANNEL_EXCLUDE")
CHANNEL_ALLOW: set[str] | None = _set_or_none("DLHDHR_CHANNEL_ALLOW")
EPG_PROVIDER: str | None = os.getenv("DLHDHR_EPG_PROVIDER")
EPG_BEST_XMLTV_URL: str | None = os.getenv("DLHDHR_EPG_BEST_XMLTV_URL")

+ 142
- 0
src/dlhdhr/dlhd.py View File

@ -0,0 +1,142 @@
from dataclasses import dataclass
import time
import urllib.parse
import lxml.etree
import httpx
import m3u8
from dlhdhr import config
from dlhdhr.tvg_id import get_tvg_id
@dataclass(frozen=True)
class DLHDChannel:
number: str
name: str
@property
def tvg_id(self) -> str | None:
return get_tvg_id(self.number)
@property
def playlist_m3u8(self) -> str:
return f"/channel/{self.number}/playlist.m3u8"
@property
def channel_proxy(self) -> str:
return f"/channel/{self.number}"
class DLHDClient:
CHANNEL_REFRESH = 60 * 60 * 12 # every 12 hours
_channels: dict[str, DLHDChannel]
_channels_last_fetch: float = 0
_base_urls: dict[DLHDChannel, (float, str)]
def __init__(self):
self._channels = {}
self._base_urls = {}
def _get_client(self, referer: str = ""):
headers = {
"User-Agent": "",
"Referer": referer,
}
return httpx.AsyncClient(
base_url=config.DLHD_BASE_URL,
headers=headers,
max_redirects=2,
verify=True,
timeout=1.0,
)
async def _refresh_channels(self):
now = time.time()
if self._channels and now - self._channels_last_fetch < DLHDClient.CHANNEL_REFRESH:
return
self._channels_last_fetch = time.time()
channels: dict[str, DLHDChannel] = {}
async with self._get_client() as client:
res = await client.get("/24-7-channels.php")
res.raise_for_status()
root = lxml.etree.HTML(res.content)
for channel_link in root.cssselect(".grid-item a"):
href: str = channel_link.get("href")
if not href:
continue
channel_number, _, _ = href.split("-")[1].partition(".")
channel_number.strip().lower()
# Skip any not explicitly defined in the allow list
if config.CHANNEL_ALLOW is not None:
if channel_number not in config.CHANNEL_ALLOW:
continue
# Skip any that are explicitly defined in the deny list
if config.CHANNEL_EXCLUDE is not None:
if channel_number in config.CHANNEL_EXCLUDE:
cotninue
channels[channel_number] = DLHDChannel(
number=channel_number, name=channel_link.cssselect("strong")[0].text.strip()
)
self._channels = channels
async def get_channels(self) -> list[DLHDChannel]:
await self._refresh_channels()
return list(self._channels.values())
async def get_channel(self, channel_number: str) -> DLHDChannel | None:
await self._refresh_channels()
return self._channels.get(channel_number)
async def get_channel_playlist(self, channel: DLHDChannel) -> m3u8.M3U8:
index_m3u8 = config.DLHD_INDEX_M3U8_PATTERN.format(channel=channel)
referer = f"https://weblivehdplay.ru/premiumtv/daddyhd.php?id={channel.number}"
async with self._get_client(referer=referer) as client:
res = await client.get(index_m3u8, follow_redirects=True)
res.raise_for_status()
playlist = m3u8.loads(res.content.decode())
# We only expect a single playlist right now
mono_url = urllib.parse.urljoin(str(res.request.url), playlist.playlists[0].uri)
res = await client.get(mono_url)
res.raise_for_status()
mono_playlist = m3u8.loads(res.content.decode())
self._base_urls[channel] = (time.time(), mono_url)
return mono_playlist
async def get_channel_base_url(self, channel: DLHDChannel) -> str:
created, base_url = self._base_urls.get(channel, (None, None))
if not created or not base_url:
# This is how we get and populate the base url
await self.get_channel_playlist(channel)
return self._base_urls[channel][0]
if (time.time() - created) > 60:
await self.get_channel_playlist(channel)
return self._base_urls[channel][0]
return base_url
async def stream_segment(self, channel: DLHDChannel, segment_path: str):
base_url = await self.get_channel_base_url(channel)
segment_url = urllib.parse.urljoin(base_url, segment_path)
async with self._get_client(referer=base_url) as client:
async with client.stream("GET", segment_url, follow_redirects=True) as res:
async for chunk in res.aiter_bytes():
yield chunk

+ 74
- 0
src/dlhdhr/ffmpeg.py View File

@ -0,0 +1,74 @@
import asyncio
class FFMpegNotStartedError(Exception):
pass
class FFMpegProcess:
_ffmpeg_command: str
_started: bool = False
_process: asyncio.subprocess.Process | None = None
def __init__(self, playlist_url: str):
self._ffmpeg_command = " ".join(
[
"ffmpeg",
"-i",
f'"{playlist_url}"',
"-vcodec",
"copy",
"-acodec",
"copy",
"-crf",
"10",
"-preset",
"ulstrafast",
"-f",
"mpegts",
"-loglevel",
"quiet",
"pipe:1",
]
)
@property
def started(self) -> bool:
return self._process is not None
def __aiter__(self) -> "FFMpegProcess":
return self
async def __anext__(self):
if not self.started:
raise FFMpegNotStartedError()
if not self._process.stdout:
raise FFMpegNotStartedError()
b = await self._process.stdout.read(512)
if not b:
raise StopAsyncIteration
return b
async def _start(self) -> None:
if not self._process:
self._process = await asyncio.subprocess.create_subprocess_shell(
self._ffmpeg_command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
def _stop(self) -> None:
if self._process:
self._process.kill()
async def __aenter__(self) -> "FFMpegProcess":
await self._start()
return self
async def __aexit__(self, _exc_type, _exc_value, _traceback) -> None:
self._stop()
def __repr__(self) -> str:
return f"FFMpegProcess<started={self.started}, command={self._ffmpeg_command:!r}>"

+ 180
- 0
src/dlhdhr/tuner.py View File

@ -0,0 +1,180 @@
import asyncio
import time
import weakref
from dlhdhr.dlhd import DLHDChannel
from dlhdhr.ffmpeg import FFMpegProcess
from m3u8.httpclient import urllib
from dlhdhr import config
class Tuner:
TUNER_TIMEOUT: int = 20
_channel: DLHDChannel
_ffmpeg_process: FFMpegProcess
_listeners: weakref.WeakSet["Tuner.Listener"]
_stream_task: asyncio.Task | None = None
class Listener:
_queue: asyncio.Queue
_stopped: bool = False
def __init__(self):
self._queue = asyncio.Queue()
def write(self, chunk: bytes | None) -> None:
if not self._stopped:
self._queue.put_nowait(chunk)
def _stop(self) -> None:
self._stopped = True
self._queue.empty()
def __aiter__(self) -> "Tuner.Listener":
return self
async def __anext__(self):
if self._stopped:
raise StopAsyncIteration()
chunk = await self._queue.get()
if not chunk:
raise StopAsyncIteration()
return chunk
def __init__(self, channel: DLHDChannel):
# We can use the binding ip/port here since it should all local traffic
base_url = f"http://{config.HOST}:{config.PORT}/"
absolute_url = urllib.parse.urljoin(base_url, channel.playlist_m3u8)
self._channel = channel
self._ffmpeg_process = FFMpegProcess(absolute_url)
self._listeners = weakref.WeakSet()
async def _stream(self) -> None:
try:
stream_timeout: float | None = None
async with self._ffmpeg_process:
async for chunk in self._ffmpeg_process:
# If there are no listeners, stream for up to 20 more seconds
# to see if a listener comes back, if not, then stop the stream
if not self._listeners:
if not stream_timeout:
stream_timeout = time.time()
elif time.time() - stream_timeout > Tuner.TUNER_TIMEOUT:
break
else:
continue
elif stream_timeout:
stream_timeout = None
for listener in self._listeners:
listener.write(chunk)
# Make sure we don't hold any hard references to
# these weakrefs
del listener
finally:
self._stop()
async def _start(self) -> None:
if self._stream_task:
return
self._stream_task = asyncio.create_task(self._stream())
def _stop(self) -> None:
if self._stream_task:
self._stream_task.cancel()
self._stream_task = None
self._ffmpeg_process._stop()
for listener in self._listeners:
listener._stop()
self._listeners.clear()
async def get_listener(self) -> "Tuner.Listener":
# Make sure to add listener before starting the stream
listener = self.Listener()
self._listeners.add(listener)
if not self._ffmpeg_process.started:
await self._start()
return listener
@property
def channel(self) -> DLHDChannel:
return self._channel
@property
def has_listeners(self) -> bool:
return bool(self.num_listeners)
@property
def num_listeners(self) -> int:
return len(self._listeners)
def __repr__(self) -> str:
return f"Tuner<channel={self.channel}, num_listeners={self.num_listeners}>"
class NoAvailableTunersError(Exception):
pass
class TunerNotFoundError(Exception):
pass
class TunerManager:
_max_tuners: int
_tuners: weakref.WeakValueDictionary[DLHDChannel, Tuner]
def __init__(self, max_tuners: int = 2) -> None:
self._max_tuners = max_tuners
self._tuners = weakref.WeakValueDictionary()
def __repr__(self) -> str:
return f"TunerManager<num_tuners={self._max_tuners}, tuners={self._tuners}>"
@property
def max_tuners(self) -> int:
return self._max_tuners
@property
def available_tuners(self) -> int:
return self.max_tuners - len(self._tuners)
@property
def total_available_listeners(self) -> int:
# We can accept more than 1 connection per-tuner
# So we want to report the total number of listeners + number of available tuners (or 1)
# This means we might actually tell the client that we have
# more tuners that we can actually allocate, but we also want
# to be sure we tell them to try to connect in case they
# want to stream an already tuned channel
total_listeners = sum(tuner.num_listeners for tuner in self._tuners.values())
if not total_listeners:
return self.max_tuners
return total_listeners + max(1, self.available_tuners)
def claim_tuner(self, channel: DLHDChannel) -> Tuner:
# Cleanup any silent tuners first
for tuner in list(self._tuners.values()):
if not tuner.has_listeners:
tuner._stop()
del self._tuners[tuner.channel]
if len(self._tuners) >= self._max_tuners:
raise NoAvailableTunersError()
if channel in self._tuners:
return self._tuners[channel]
tuner = Tuner(channel)
self._tuners[channel] = tuner
return tuner

+ 330
- 0
src/dlhdhr/tvg_id.py View File

@ -0,0 +1,330 @@
# DLHD channel number -> tvg_id
_MAPPING: dict[str, str] = dict(
[
("31", "TNTSport1.uk"),
("32", "TNTSport2.uk"),
("33", "TNTSport3.uk"),
("34", "TNTSport4.uk"),
("35", "SkySportsFootball.uk"),
("36", "SkySportsArena.uk"),
("37", "SkySportsAction.uk"),
("39", "FoxSports1.us"),
("40", "TennisChannel.us"),
("41", "Eurosport1.uk"),
("42", "Eurosport2.uk"),
("44", "ESPN.us"),
("45", "ESPN2.us"),
("47", "PolsatSport.pl"),
("48", "CanalPlusSport.pl"),
("49", "SportTV1.pt"),
("50", "PolsatSportExtra.pl"),
("58", "Eurosport2.pl"),
("60", "SkySportsF1.uk"),
("62", "beINSports1.tr"),
("63", "beINSports2.tr"),
("64", "beINSports3.tr"),
("66", "TUDN.us"),
("67", "beINSports4.tr"),
("70", "SkySportsGolf.uk"),
("73", "CanalPlusSport2.pl"),
("74", "SportTV2.pt"),
("80", "SporTV3.br"),
("81", "ESPN.br"),
("82", "ESPN2.br"),
("87", "TNT.br"),
("89", "Combate.br"),
("101", "SportKlub1.rs"),
("102", "SportKlub2.rs"),
("103", "SportKlub3.rs"),
("104", "SportKlub4.rs"),
("119", "RMCSport1.fr"),
("120", "RMCSport2.fr"),
("121", "CanalPlus.fr"),
("122", "CanalPlusSport.fr"),
("127", "MatchTV.ru"),
("128", "TVPSport.pl"),
("129", "PolsatSportNews.pl"),
("130", "SkySportsPremiereLeague.uk"),
("140", "Sport1.il"),
("141", "Sport2.il"),
("142", "Sport3.il"),
("143", "Sport4.il"),
("289", "SportTV4.pt"),
("290", "SportTV5.pt"),
("291", "SportTV6.pt"),
("292", "NewsNation.us"),
("293", "ReelzChannel.us"),
("295", "AdultSwim.us"),
("297", "FoxBusiness.us"),
("298", "FXX.us"),
("299", "MagnoliaNetwork.us"),
("301", "Freeform.us"),
("303", "AMC.us"),
("304", "AnimalPlanet.us"),
("305", "BBCAmerica.us"),
("306", "BET.us"),
("307", "Bravo.us"),
("309", "CNBC.us"),
("310", "ComedyCentral.us"),
("312", "DisneyChannel.us"),
("313", "DiscoveryChannel.us"),
("314", "DisneyXD.us"),
("316", "ESPNU.us"),
("317", "FX.us"),
("319", "GameShowNetwork.us"),
("321", "HBO.us"),
("325", "ION.us"),
("326", "LifetimeNetwork.us"),
("327", "MSNBC.us"),
("329", "NickJr.us"),
("330", "Nickelodeon.us"),
("331", "OprahWinfreyNetwork.us"),
("333", "Showtime.us"),
("334", "ParamountNetwork.us"),
("335", "Starz.us"),
("336", "TBS.us"),
("337", "TLC.us"),
("338", "TNT.us"),
("339", "CartoonNetwork.us"),
("340", "TravelChannel.us"),
("343", "USANetwork.us"),
("344", "VH1.us"),
("345", "CNN.us"),
("346", "WillowCricket.us"),
("347", "FoxNews.us"),
("348", "Dave.uk"),
("350", "ITV1.uk"),
("351", "ITV2.uk"),
("352", "ITV3.uk"),
("353", "ITV4.uk"),
("354", "Channel4.uk"),
("355", "Channel5.uk"),
("356", "BBC1.uk"),
("357", "BBC2.uk"),
("358", "BBC3.uk"),
("359", "BBC4.uk"),
("366", "SkySportsNews.uk"),
("367", "MTV.uk"),
("371", "MTV.us"),
("373", "Syfy.us"),
("374", "Cinemax.us"),
("375", "ESPNDeportes.us"),
("377", "MUTV.uk"),
("379", "ESPN1.nl"),
("381", "FXMovieChannel.us"),
("382", "HGTV.us"),
("383", "ZiggoSportDocu.nl"),
("385", "SECNetwork.us"),
("386", "ESPN2.nl"),
("388", "TNTSports.ar"),
("390", "RTL7.nl"),
("393", "ZiggoSportSelect.nl"),
("396", "ZiggoSportRacing.nl"),
("398", "ZiggoSportVoetbal.nl"),
("399", "MLBNetwork.us"),
("400", "DigiSport1.ro"),
("401", "DigiSport2.ro"),
("402", "DigiSport3.ro"),
("403", "DigiSport4.ro"),
("404", "NBATV.us"),
("405", "NFLNetwork.us"),
("432", "ArenaSport1.hr"),
("433", "ArenaSport2.hr"),
("434", "ArenaSport3.hr"),
("438", "MovistarDeportes2.es"),
("439", "OrangeSport1.ro"),
("440", "OrangeSport2.ro"),
("441", "OrangeSport3.ro"),
("442", "OrangeSport4.ro"),
("443", "PolsatNews.pl"),
("444", "TVN24.pl"),
("445", "DAZN1.es"),
("446", "DAZN2.es"),
("447", "DAZN3.es"),
("448", "DAZN4.es"),
("451", "ViaplaySports1.uk"),
("454", "SportTV3.pt"),
("455", "ElevenSports1.pt"),
("456", "ElevenSports2.pt"),
("457", "ElevenSports3.pt"),
("458", "ElevenSports4.pt"),
("459", "ElevenSports5.pt"),
("460", "SkySportFootball.it"),
("462", "SkySportArena.it"),
("463", "CanalPlusFoot.fr"),
("465", "DiemaSport.bg"),
("466", "DiemaSport2.bg"),
("467", "DiemaSport3.bg"),
("468", "NovaSport.bg"),
("469", "Eurosport1.bg"),
("470", "Eurosport2.bg"),
("472", "MAXSport1.bg"),
("473", "MAXSport2.bg"),
("474", "MAXSport3.bg"),
("475", "MAXSport4.bg"),
("476", "BNT1.bg"),
("477", "BNT2.bg"),
("479", "bTV.bg"),
("481", "bTVAction.bg"),
("482", "Diema.bg"),
("484", "bTVLady.bg"),
("485", "DiemaFamily.bg"),
("523", "RealMadridTV.es"),
("524", "Eurosport1.es"),
("525", "Eurosport2.es"),
("526", "MovistarDeportes3.es"),
("528", "MovistarGolf.es"),
("531", "Antena3.es"),
("532", "Telecinco.es"),
("534", "LaSexta.es"),
("535", "Cuatro.es"),
("537", "DAZNF1.es"),
("540", "Canal11.pt"),
("543", "YesMoviesAction.il"),
("544", "YesMoviesKids.il"),
("545", "YesMoviesComedy.il"),
("546", "Channel9.il"),
("550", "ViaplaySports2.uk"),
("553", "HOT3.il"),
("554", "SkySportsRacing.uk"),
("556", "SkySportTopEvent.de"),
("557", "SkySportMix.de"),
("560", "TVP1.pl"),
("561", "TVP2.pl"),
("562", "Polsat.pl"),
("564", "PolsatFilm.pl"),
("566", "CanalPlusPremium.pl"),
("567", "CanalPlusFamily.pl"),
("569", "HBO.pl"),
("570", "CanalPlusSeriale.pl"),
("573", "MatchPremier.ru"),
("575", "SkySportMotoGP.it"),
("576", "SkySportTennis.it"),
("577", "SkySportF1.it"),
("580", "ArenaSport4.hr"),
("582", "NovaSport.rs"),
("587", "SkySportSelect.nz"),
("588", "SkySport1.nz"),
("589", "SkySport2.nz"),
("590", "SkySport3.nz"),
("591", "SkySport4.nz"),
("592", "SkySport5.nz"),
("593", "SkySport6.nz"),
("594", "SkySport7.nz"),
("595", "SkySport8.nz"),
("596", "SkySport9.nz"),
("597", "ViaplayXtra.uk"),
("600", "AbuDhabiSports1.ae"),
("601", "SmithsonianChannel.us"),
("613", "Newsmax.us"),
("631", "NovaSports1.gr"),
("632", "NovaSports2.gr"),
("633", "NovaSports3.gr"),
("634", "NovaSports4.gr"),
("635", "NovaSports5.gr"),
("636", "NovaSports6.gr"),
("637", "NovaSportsStart.gr"),
("638", "NovaSportsPrime.gr"),
("639", "NovaSportsNews.gr"),
("640", "Sport1Plus.de"),
("641", "Sport1.de"),
("646", "MAVTV.us"),
("647", "CMT.us"),
("648", "Boomerang.us"),
("649", "Nicktoons.us"),
("650", "TeenNick.us"),
("651", "DestinationAmerica.us"),
("657", "DiscoveryFamily.us"),
("658", "SundanceTV.us"),
("661", "MotorTrend.us"),
("663", "NHLNetwork.us"),
("664", "ACCNetwork.us"),
("665", "FYI.us"),
("666", "NickMusic.us"),
("667", "LonghornNetwork.us"),
("668", "UniversalKids.us"),
("670", "S4C.uk"),
("671", "SkyCinemaPremiere.uk"),
("673", "SkyCinemaHits.uk"),
("674", "SkyCinemaGreats.uk"),
("675", "SkyCinemaAnimation.uk"),
("676", "SkyCinemaFamily.uk"),
("679", "SkyCinemaThriller.uk"),
("682", "SkyShowcase.uk"),
("683", "SkyArts.uk"),
("684", "SkyComedy.uk"),
("688", "Film4.uk"),
("689", "HBO2.us"),
("690", "HBOComedy.us"),
("691", "HBOFamily.us"),
("692", "HBOLatino.us"),
("693", "HBOSignature.us"),
("694", "HBOZone.us"),
("696", "Comet.us"),
("697", "CookingChannel.us"),
("699", "CBC.ca"),
("715", "CleoTV.us"),
("716", "SportingTV.pt"),
("717", "AXNMovies.pt"),
("726", "3sat.de"),
("734", "WDR.de"),
("735", "SWR.de"),
("739", "SRFernsehen.de"),
("742", "AXSTV.us"),
("745", "NatGeoWild.us"),
("746", "TYCSports.ar"),
("753", "NBCSportsBayArea.us"),
("754", "SportsBoston.us"),
("755", "NBCSportsCalifornia.us"),
("758", "FoxSports2.us"),
("763", "YESNetwork.us"),
("766", "WABC.us"),
("767", "FoxSports.ar"),
("770", "MarqueeSportsNetwork.us"),
("776", "NBCSportsChicago.us"),
("777", "NBCSportsPhiladelphia.us"),
("778", "NBCSportsWashington.us"),
("779", "MAXSport1.hr"),
("780", "MAXSport2.hr"),
("788", "FoxSports2.ar"),
("789", "FoxSports3.ar"),
("801", "DR1.dk"),
("802", "DR2.dk"),
("803", "Kanal4.dk"),
("804", "Kanal5.dk"),
("806", "MTV.dk"),
("807", "TV2Bornholm.dk"),
("808", "TV2SportX.dk"),
("809", "TV3Sport.dk"),
("810", "TV2Sport.dk"),
("817", "TV2.dk"),
("819", "TV3Plus.dk"),
("829", "MASN.us"),
("832", "CBC.ca"),
("835", "Noovo.ca"),
("836", "Global.ca"),
("839", "RDS.ca"),
("840", "RDS2.ca"),
("841", "RDSInfo.ca"),
("854", "Italia1.it"),
("855", "La7.it"),
("857", "20Mediaset.it"),
("858", "RaiPremium.it"),
("859", "SkyCinemaCollection.it"),
("860", "SkyCinemaUno.it"),
("861", "SkyCinemaAction.it"),
("864", "SkyCinemaRomance.it"),
("865", "SkyCinemaFamily.it"),
("866", "SkyCinemaDuePlus24.it"),
("867", "SkyCinemaDrama.it"),
("869", "SkySport24.it"),
("870", "SkySportCalcio.it"),
("879", "Eurosport2.it"),
("880", "SkySerie.it"),
("882", "RaiSport.it"),
]
)
def get_tvg_id(channel_number: str) -> str | None:
return _MAPPING.get(channel_number)

Loading…
Cancel
Save