mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-06 07:52:26 +02:00
Template README - additions
This commit is contained in:
@@ -6,52 +6,119 @@ and fill in the stubs. Read this file first — it explains the contract.
|
||||
## What you get for free
|
||||
|
||||
- HTTP manager setup, proxying, retries.
|
||||
- Credential storage / Kodi sync via the base `settings_manager`.
|
||||
- Token caching and session persistence (in your Auth class).
|
||||
- Credential storage / Kodi sync via the base `settings_manager` (if the
|
||||
provider needs credentials).
|
||||
- Token caching and session persistence (in your Auth class, if you have one).
|
||||
- Capability flags (`implements_vod`, `implements_epg`, `implements_recordings`,
|
||||
...) — derived from whether you wire up the corresponding manager.
|
||||
- Shared error types, shared `VodPage` shape, shared `Channel` base.
|
||||
|
||||
## What you implement
|
||||
|
||||
1. **Auth** — `auth.py`. Writes the three shared methods and any optional
|
||||
extensions the provider needs.
|
||||
2. **Managers** — one file per capability the provider supports. Each
|
||||
1. **Provider** — `provider.py`. Required. Declares the provider's class
|
||||
metadata, wires up whatever managers it has, and exposes the public
|
||||
interface.
|
||||
2. **Auth** — `auth.py`. Optional. Required only if the provider
|
||||
authenticates requests. Free / static-key providers can omit it
|
||||
entirely, or provide a minimal `Auth` that only sets base headers.
|
||||
3. **Managers** — one file per capability the provider supports. Each
|
||||
subclasses the corresponding ABC from `base/managers/` and implements
|
||||
the abstract methods. See "The manager ABCs" below for the full list.
|
||||
3. **Provider wiring** — `provider.py`. Fills in the `_build_*` factory
|
||||
methods; returns `None` for capabilities the provider doesn't have.
|
||||
the abstract methods. **All seven managers are optional** — a
|
||||
VOD-only provider has no `ChannelManager`; a linear-only provider has
|
||||
no `VodManager`; a favorites-sync-only provider may have neither.
|
||||
See "The manager ABCs" below.
|
||||
4. **Constants** — `constants.py`. URLs, endpoints, static headers.
|
||||
5. **Models** (optional) — `models.py`. Only if you need a custom Channel
|
||||
or AuthToken subclass.
|
||||
|
||||
## Provider class metadata
|
||||
|
||||
Every provider declares these class attributes. They are used by the
|
||||
registry and the UI before any instance is constructed.
|
||||
|
||||
PROVIDER_LABEL: ClassVar[str] # display name, e.g. "simpliTV"
|
||||
PROVIDER_LOGO: ClassVar[str] # logo URL
|
||||
SUPPORTED_AUTH_TYPES: ClassVar[List[str]] # e.g. ["user_credentials"]
|
||||
SUPPORTED_COUNTRIES: ClassVar[List[str]] # ALWAYS set this
|
||||
|
||||
### SUPPORTED_COUNTRIES is not optional
|
||||
|
||||
**Always declare `SUPPORTED_COUNTRIES`.** Never leave it at the base
|
||||
default. The base class defines it as an empty list, which has a
|
||||
specific meaning: "the provider does not support country-specific
|
||||
instances." That meaning is easy to collide with the accidental case of
|
||||
"the author forgot to declare it," and the failure mode is silent — the
|
||||
provider shows up in every country's list or in none, depending on
|
||||
which code path reads it.
|
||||
|
||||
Declare it explicitly, even for a single-country provider:
|
||||
|
||||
# Single country
|
||||
SUPPORTED_COUNTRIES: ClassVar[List[str]] = ["AT"]
|
||||
|
||||
# Multi-country with per-country instances
|
||||
SUPPORTED_COUNTRIES: ClassVar[List[str]] = ["hr", "pl", "me", "at", "hu"]
|
||||
|
||||
# Multi-country with per-country instances but no explicit list —
|
||||
# the provider discovers its country at runtime (Discovery+ shape)
|
||||
SUPPORTED_COUNTRIES: ClassVar[List[str]] = ["*"]
|
||||
|
||||
The three cases:
|
||||
|
||||
* **Single country** — one-element list. The registry creates one
|
||||
instance for that country.
|
||||
* **Multi-country** — one instance per listed country. `country` is a
|
||||
constructor argument; each instance is independent.
|
||||
* **Wildcard** — `["*"]`. The registry creates one instance for the
|
||||
default country; the provider discovers its actual country at
|
||||
runtime (e.g. from a `/users/me` call).
|
||||
|
||||
An empty list is reserved for providers with no country concept at all
|
||||
(a purely global, country-agnostic service). If you find yourself
|
||||
wanting to leave it empty because "I'm not sure yet," declare `["*"]`
|
||||
instead — it's honest about the ambiguity and behaves correctly in
|
||||
both the registry and the runtime.
|
||||
|
||||
## The manager ABCs
|
||||
|
||||
There are **seven** manager ABCs. Three are required capabilities, four
|
||||
are optional. Providers implement the ones their service offers and
|
||||
return `None` from the corresponding `_build_*()` for the rest.
|
||||
|
||||
### Required capabilities
|
||||
There are **seven** manager ABCs. **All seven are optional.** A provider
|
||||
implements the ones its service offers and returns `None` from the
|
||||
corresponding `_build_*()` for the rest.
|
||||
|
||||
ChannelManager -- live channels, channel manifest, channel DRM
|
||||
VodManager -- browseable VOD catalogue (None for live-only)
|
||||
EpgManager -- EPG (None for providers without EPG)
|
||||
|
||||
### Optional capabilities
|
||||
|
||||
VodManager -- browseable VOD catalogue
|
||||
EpgManager -- EPG
|
||||
RecordingsManager -- cloud / network PVR
|
||||
FavoritesManager -- user favorites on programs / channels
|
||||
BookmarksManager -- resume positions
|
||||
CatchupManager -- timeshift / restart
|
||||
|
||||
**The rule: if a capability area has a public interface in the base
|
||||
layer, it gets a manager ABC.** The base layer's
|
||||
layer, it gets a manager ABC. If the provider has the capability, wire
|
||||
the manager; if not, return `None` from the factory.** The base layer's
|
||||
`ProviderRecordingsMixin`, `ProviderFavoritesMixin`,
|
||||
`ProviderBookmarksMixin`, and `ProviderCatchupMixin` correspond
|
||||
one-to-one to the four optional managers. Do not fold a capability into
|
||||
another manager because the data happens to come from the same
|
||||
endpoint — the public interface is the contract, not the URL.
|
||||
|
||||
### Providers vary in which managers they have
|
||||
|
||||
Common shapes:
|
||||
|
||||
Linear-only free provider -> ChannelManager + EpgManager
|
||||
Linear + catchup -> ChannelManager + EpgManager + CatchupManager
|
||||
VOD-only -> VodManager
|
||||
VOD + linear -> ChannelManager + VodManager
|
||||
Linear + recordings -> ChannelManager + RecordingsManager (+ EpgManager)
|
||||
Metadata-only (EPG feed) -> EpgManager only
|
||||
Favorites-sync-only -> FavoritesManager only
|
||||
|
||||
Do not assume "every provider has channels." Do not assume "every
|
||||
provider has VOD." Do not assume "every provider has EPG." If a
|
||||
capability is missing, `_build_*()` returns `None`, the capability flag
|
||||
is `False`, and callers that gate on the flag skip it cleanly.
|
||||
|
||||
### Constructor contract
|
||||
|
||||
All seven managers share the same constructor contract:
|
||||
@@ -380,7 +447,55 @@ helper should either be renamed public or moved to a shared module
|
||||
## The Auth protocol
|
||||
|
||||
Auth is a documented protocol (see `base/protocols.py`), not an ABC.
|
||||
Every provider writes:
|
||||
**Auth is optional** — a free provider that never authenticates requests
|
||||
does not need one at all.
|
||||
|
||||
### Providers without auth
|
||||
|
||||
Some providers need no authentication:
|
||||
|
||||
* Free, public-content providers with no user accounts.
|
||||
* Static-API-key providers where the key never changes and is best
|
||||
expressed in `constants.py` headers.
|
||||
* Providers where every request is anonymous and no session state is
|
||||
carried.
|
||||
|
||||
For these, `_build_auth()` returns `None`, `self.auth = None`, and any
|
||||
manager that receives `auth=None` must not call its methods. This is
|
||||
supportable but produces a warning from the manager base constructors
|
||||
(the `AuthProtocol` isinstance check fires). If you are writing a
|
||||
provider with no auth:
|
||||
|
||||
* Return `None` from `_build_auth()`.
|
||||
* Either accept the warning (it is non-fatal — the manager still
|
||||
constructs and runs), or
|
||||
* Provide a minimal `Auth` stub that only implements `build_headers()`
|
||||
and returns the static headers. This is usually cheaper than
|
||||
suppressing the warning, because managers that call `auth.build_headers()`
|
||||
still work.
|
||||
|
||||
Minimal no-auth stub:
|
||||
|
||||
class YourNoAuth:
|
||||
"""Auth stub for a provider with no authentication."""
|
||||
|
||||
def get_access_token(self, force_refresh=False):
|
||||
return ""
|
||||
|
||||
def build_headers(self, token=None, **opts):
|
||||
return {"User-Agent": "...", "Accept": "application/json"}
|
||||
|
||||
def invalidate(self):
|
||||
pass
|
||||
|
||||
Wire it as `self.auth = YourNoAuth()` in `_build_auth()`. The manager
|
||||
ABCs' isinstance check passes (all three required methods are present),
|
||||
and `build_headers()` returns whatever static headers the provider
|
||||
needs.
|
||||
|
||||
### Providers with auth
|
||||
|
||||
Every provider with auth writes:
|
||||
|
||||
get_access_token(force_refresh=False) -> str
|
||||
Raw token string. No scheme prefix.
|
||||
@@ -422,7 +537,10 @@ assume a bearer token is being sent.
|
||||
The manager ABCs verify `auth` against `AuthProtocol` at construction
|
||||
time via `isinstance` (this works because the protocol is
|
||||
`@runtime_checkable`). The check confirms method *presence*, not
|
||||
signatures — a mismatched signature will not be caught here.
|
||||
signatures — a mismatched signature will not be caught here. A warning
|
||||
from this check means the `auth` object is missing one of the three
|
||||
required methods; it is not fatal, but it usually means a wiring
|
||||
mistake.
|
||||
|
||||
## DRM
|
||||
|
||||
@@ -661,6 +779,9 @@ If your provider has a strong reason to authenticate eagerly (e.g. you
|
||||
need to fail fast on bad credentials), do it in the provider's `__init__`
|
||||
inside a try/except and log a warning — do not raise.
|
||||
|
||||
For providers with no auth, this section does not apply — there is
|
||||
nothing to authenticate.
|
||||
|
||||
## Reference providers
|
||||
|
||||
The six existing providers, in order of implementation complexity:
|
||||
|
||||
@@ -7,6 +7,10 @@ the public StreamingProvider interface.
|
||||
|
||||
Subclasses the existing StreamingProvider. Does NOT subclass any new
|
||||
base class. Authentication is lazy -- no network I/O in __init__.
|
||||
|
||||
All seven managers are optional. This template shows the shape for a
|
||||
provider that has channels, VOD, and EPG. Delete the factories for
|
||||
capabilities you don't have, or return None from them.
|
||||
"""
|
||||
|
||||
from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple
|
||||
@@ -23,6 +27,10 @@ from .channel_manager import YourChannelManager
|
||||
from .constants import YourConfig
|
||||
# from .vod_manager import YourVodManager
|
||||
# from .epg_manager import YourEpgManager
|
||||
# from .recordings_manager import YourRecordingsManager
|
||||
# from .favorites_manager import YourFavoritesManager
|
||||
# from .bookmarks_manager import YourBookmarksManager
|
||||
# from .catchup_manager import YourCatchupManager
|
||||
# from .drm_manager import YourDrmManager
|
||||
|
||||
|
||||
@@ -32,18 +40,22 @@ class YourProvider(StreamingProvider):
|
||||
PROVIDER_LABEL: ClassVar[str] = "TODO: display label"
|
||||
PROVIDER_LOGO: ClassVar[str] = "TODO: logo url"
|
||||
SUPPORTED_AUTH_TYPES: ClassVar[List[str]] = ["user_credentials"]
|
||||
SUPPORTED_COUNTRIES: ClassVar[List[str]] = ["TODO", "country", "codes"]
|
||||
|
||||
# Module-private constants used by get_drm's folded path.
|
||||
# ALWAYS set SUPPORTED_COUNTRIES. Never leave it at the base
|
||||
# default (an empty list), which has a specific meaning: "no
|
||||
# country concept at all." Even a single-country provider declares
|
||||
# a one-element list.
|
||||
#
|
||||
# Only these two values narrow the search when content_type is
|
||||
# provided. Anything else (None, "event", "catchup", or a typo)
|
||||
# tries both domains. This is deliberate: a wrong narrowing produces
|
||||
# a silent [] for protected content, which is the hardest kind of
|
||||
# bug to trace. Widening on unknown input is always safe.
|
||||
# Single country: ["AT"]
|
||||
# Multi-country: ["hr", "pl", "me", "at", "hu"]
|
||||
# Wildcard: ["*"] (country discovered at runtime)
|
||||
#
|
||||
# Adding more narrowing values here is a deliberate act and should
|
||||
# be justified by a caller that reliably knows the content type.
|
||||
# See the README's "SUPPORTED_COUNTRIES is not optional" section.
|
||||
SUPPORTED_COUNTRIES: ClassVar[List[str]] = ["TODO"]
|
||||
|
||||
# Only "live" and "vod" narrow the folded DRM search; anything else
|
||||
# (None, "event", "catchup", a typo) tries both domains. See the
|
||||
# README's "content_type hint semantics".
|
||||
_LIVE_ONLY_CONTENT_TYPES = frozenset({"live"})
|
||||
_VOD_ONLY_CONTENT_TYPES = frozenset({"vod"})
|
||||
|
||||
@@ -53,6 +65,7 @@ class YourProvider(StreamingProvider):
|
||||
config: Optional[Dict] = None,
|
||||
proxy_config: Optional[ProxyConfig] = None,
|
||||
settings_manager=None,
|
||||
credentials=None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(country)
|
||||
@@ -60,7 +73,7 @@ class YourProvider(StreamingProvider):
|
||||
config = config or {}
|
||||
self.config = YourConfig(config)
|
||||
|
||||
# 1. HTTP manager (existing StreamingProvider helper).
|
||||
# 1. HTTP manager.
|
||||
self.http_manager = self._setup_http_manager(
|
||||
provider_name="TODO: provider_name",
|
||||
proxy_config=proxy_config,
|
||||
@@ -68,17 +81,30 @@ class YourProvider(StreamingProvider):
|
||||
timeout=self.config.timeout,
|
||||
)
|
||||
|
||||
# 2. Auth (protocol, not ABC). Lazy -- no network call here.
|
||||
# 2. Auth (lazy -- no network call in __init__).
|
||||
#
|
||||
# Providers WITHOUT auth: leave self.auth = None, and either
|
||||
# accept the manager base constructors' AuthProtocol warning,
|
||||
# or provide a minimal stub with the three methods. See the
|
||||
# README's "Providers without auth" section.
|
||||
self._credentials = credentials
|
||||
self.auth = self._build_auth(settings_manager)
|
||||
|
||||
# 3. Provider-owned caches. Managers borrow these by reference.
|
||||
# Add caches here as the provider needs them.
|
||||
self._channels_cache: Dict = {}
|
||||
self._playback_cache: Dict = {}
|
||||
|
||||
# 4. Managers.
|
||||
# 4. Managers. Every factory returns a manager or None.
|
||||
# Delete the lines for capabilities you don't have, or leave
|
||||
# the corresponding _build_* returning None.
|
||||
self.channels = self._build_channels()
|
||||
self.vod = self._build_vod()
|
||||
self.epg = self._build_epg()
|
||||
self.recordings = self._build_recordings()
|
||||
self.favorites = self._build_favorites()
|
||||
self.bookmarks = self._build_bookmarks()
|
||||
self.catchup = self._build_catchup()
|
||||
self.drm = self._build_drm()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -86,13 +112,27 @@ class YourProvider(StreamingProvider):
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_auth(self, settings_manager):
|
||||
"""
|
||||
Return the provider's Auth instance, or None if the provider
|
||||
needs no authentication.
|
||||
|
||||
See the README's "Providers without auth" section for the
|
||||
minimal stub shape.
|
||||
"""
|
||||
return YourProviderAuth(
|
||||
http_manager=self.http_manager,
|
||||
country=self.country,
|
||||
settings_manager=settings_manager,
|
||||
)
|
||||
|
||||
def _build_channels(self):
|
||||
def _build_channels(self) -> Optional[ChannelManager]:
|
||||
"""
|
||||
Return a ChannelManager, or None if the provider has no live
|
||||
channels.
|
||||
|
||||
A VOD-only provider returns None here. A free linear-only
|
||||
provider returns a manager here and None from _build_vod.
|
||||
"""
|
||||
return YourChannelManager(
|
||||
http_manager=self.http_manager,
|
||||
auth=self.auth,
|
||||
@@ -101,7 +141,11 @@ class YourProvider(StreamingProvider):
|
||||
channels_cache=self._channels_cache,
|
||||
)
|
||||
|
||||
def _build_vod(self):
|
||||
def _build_vod(self) -> Optional[VodManager]:
|
||||
"""
|
||||
Return a VodManager, or None if the provider has no browseable
|
||||
VOD catalogue.
|
||||
"""
|
||||
# TODO: return YourVodManager(
|
||||
# http_manager=self.http_manager,
|
||||
# auth=self.auth,
|
||||
@@ -112,35 +156,56 @@ class YourProvider(StreamingProvider):
|
||||
return None
|
||||
|
||||
def _build_epg(self):
|
||||
"""
|
||||
Return an EpgManager, or None if the provider has no EPG.
|
||||
|
||||
Some providers have channels but no EPG; some have EPG but no
|
||||
channels. The two capabilities are independent.
|
||||
"""
|
||||
return None
|
||||
|
||||
def _build_recordings(self):
|
||||
"""Return a RecordingsManager, or None."""
|
||||
return None
|
||||
|
||||
def _build_favorites(self):
|
||||
"""Return a FavoritesManager, or None."""
|
||||
return None
|
||||
|
||||
def _build_bookmarks(self):
|
||||
"""Return a BookmarksManager, or None."""
|
||||
return None
|
||||
|
||||
def _build_catchup(self):
|
||||
"""
|
||||
Return a CatchupManager, or None.
|
||||
|
||||
Catchup usually needs the channel manager as a collaborator
|
||||
(to reuse the live manifest fetch). Ensure
|
||||
self.channels is built before this factory runs.
|
||||
"""
|
||||
return None
|
||||
|
||||
def _build_drm(self) -> Optional[DrmManagerProtocol]:
|
||||
"""
|
||||
Return a dedicated DRM manager, or None.
|
||||
|
||||
Two supported architectures (see the README's "DRM" section for
|
||||
the state-sharing rule that picks between them):
|
||||
Two supported architectures (see the README's "DRM" section):
|
||||
|
||||
* Dedicated manager: return a class matching DrmManagerProtocol
|
||||
here; the provider's get_drm() delegates to it.
|
||||
* Dedicated manager: return a class matching
|
||||
DrmManagerProtocol here; the provider's get_drm() delegates
|
||||
to it.
|
||||
|
||||
* Folded into managers: leave this returning None, and instead
|
||||
* Folded into managers: leave this returning None, and
|
||||
override get_channel_drm() on your ChannelManager and/or
|
||||
get_vod_drm() on your VodManager. The provider's get_drm()
|
||||
falls back to routing to those.
|
||||
|
||||
New providers should prefer the dedicated manager unless the DRM
|
||||
step shares state with the manifest step. See
|
||||
providers/_template/drm_manager.py for the four existing source
|
||||
patterns.
|
||||
New providers should prefer the dedicated manager unless the
|
||||
DRM step shares state with the manifest step. See
|
||||
providers/_template/drm_manager.py for the four existing
|
||||
source patterns.
|
||||
"""
|
||||
# TODO: return YourDrmManager(
|
||||
# http_manager=self.http_manager,
|
||||
# auth=self.auth,
|
||||
# country=self.country,
|
||||
# config=self.config,
|
||||
# # ... provider-specific collaborators
|
||||
# )
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -159,6 +224,22 @@ class YourProvider(StreamingProvider):
|
||||
def implements_epg(self) -> bool:
|
||||
return self.epg is not None
|
||||
|
||||
@property
|
||||
def implements_recordings(self) -> bool:
|
||||
return self.recordings is not None
|
||||
|
||||
@property
|
||||
def implements_favorites(self) -> bool:
|
||||
return self.favorites is not None
|
||||
|
||||
@property
|
||||
def implements_bookmarks(self) -> bool:
|
||||
return self.bookmarks is not None
|
||||
|
||||
@property
|
||||
def implements_catchup(self) -> bool:
|
||||
return self.catchup is not None
|
||||
|
||||
@property
|
||||
def implements_drm(self) -> bool:
|
||||
"""
|
||||
@@ -170,8 +251,7 @@ class YourProvider(StreamingProvider):
|
||||
* a VodManager that overrides get_vod_drm.
|
||||
|
||||
The base classes' defaults return []; we detect overrides by
|
||||
comparing the bound method against the base class's method. This
|
||||
is what makes the flag correct for the folded architecture.
|
||||
comparing the bound method against the base class's method.
|
||||
"""
|
||||
if self.drm is not None:
|
||||
return True
|
||||
@@ -205,11 +285,6 @@ class YourProvider(StreamingProvider):
|
||||
If nobody resolved and a NotFoundError was seen, re-raise it --
|
||||
that's "the content existed in some manager's domain but is gone",
|
||||
distinct from "nobody handles this id at all" (which returns None).
|
||||
|
||||
BadRequestError and other errors are NOT caught here. Providers
|
||||
that need endpoint-fallback behavior (Magenta's page-vs-component
|
||||
dispatch) should override handles_content_id() instead, so the
|
||||
router never has to guess.
|
||||
"""
|
||||
last_not_found: Optional[NotFoundError] = None
|
||||
for manager, call in attempts:
|
||||
@@ -239,13 +314,17 @@ class YourProvider(StreamingProvider):
|
||||
"""
|
||||
Return the manifest URL for the given content, routing by manager.
|
||||
|
||||
Providers with events, catchup, or other content types extend
|
||||
this method to add their branches. Each branch is a
|
||||
(manager, call) tuple in the attempts list.
|
||||
Providers with catchup, events, or other content types extend
|
||||
this method with additional branches. Providers with a
|
||||
structured content_id grammar add explicit prefix branches
|
||||
above _route when the manager needs parsed arguments -- see the
|
||||
README's "Parsers vs. dispatch".
|
||||
"""
|
||||
return self._route(content_id, [
|
||||
(self.channels, lambda m: m.get_channel_manifest(content_id, **kw)),
|
||||
(self.vod, lambda m: m.get_vod_manifest(content_id, **kw)),
|
||||
(self.channels, lambda m: m.get_channel_manifest(
|
||||
content_id, **kw
|
||||
)),
|
||||
(self.vod, lambda m: m.get_vod_manifest(content_id, **kw)),
|
||||
])
|
||||
|
||||
def get_drm(
|
||||
@@ -266,21 +345,18 @@ class YourProvider(StreamingProvider):
|
||||
|
||||
If a dedicated DRM manager is configured, the hint is passed
|
||||
through unchanged and the manager decides what to do with it.
|
||||
Otherwise the folded path narrows the search as described above.
|
||||
"""
|
||||
if self.drm is not None:
|
||||
return self.drm.get_drm_configs(
|
||||
content_id,
|
||||
content_type=content_type,
|
||||
**kw,
|
||||
content_id, content_type=content_type, **kw
|
||||
)
|
||||
|
||||
# Folded path: invert the check so only recognized values narrow.
|
||||
# Unknown values (including typos) fall through to "try both".
|
||||
attempts: List[Tuple[Any, Callable]] = []
|
||||
if content_type not in self._VOD_ONLY_CONTENT_TYPES:
|
||||
attempts.append(
|
||||
(self.channels, lambda m: m.get_channel_drm(content_id, **kw))
|
||||
(self.channels, lambda m: m.get_channel_drm(
|
||||
content_id, **kw
|
||||
))
|
||||
)
|
||||
if content_type not in self._LIVE_ONLY_CONTENT_TYPES:
|
||||
attempts.append(
|
||||
|
||||
Reference in New Issue
Block a user