updates 1.4.5

This commit is contained in:
VineFeeder
2025-09-10 15:11:21 +01:00
parent e04d4f1eb7
commit 18f804a02d
4 changed files with 74 additions and 36 deletions
+7
View File
@@ -19,6 +19,13 @@ The prime reason for the existence of envied is a --select-titles function.
If you already use envied you'll probably just want to replace envied/envied/envied.yaml If you already use envied you'll probably just want to replace envied/envied/envied.yaml
with your own. But the exisiting yaml is close to working - just needs a few directory locations. with your own. But the exisiting yaml is close to working - just needs a few directory locations.
## Divergence from Envied's Parent
- **select-titles option** avoid the uncertainty of -w S26E03 gobbledegook to get your video
- **Singleton Design Pattern** Good coding practice: where possible, a single instance of a Class is created and re-used. Saving time and resources.
- **Multitron Design Pattern** For those times when a Singleton will not do. Re-use Classes with care for the calling parameters.
- **Clear Branding** Clear presentation of the program name!
- **No Free Ride for Spammers** Envied will not implement any methods used to connect to Decrypt Labs or other payfor-CDM-access sites
**Recommended:** Use `uv run envied` instead of direct command execution to ensure proper virtual environment activation. **Recommended:** Use `uv run envied` instead of direct command execution to ensure proper virtual environment activation.
+2 -2
View File
@@ -1272,7 +1272,7 @@ class dl:
if isinstance(drm, Widevine): if isinstance(drm, Widevine):
with self.DRM_TABLE_LOCK: with self.DRM_TABLE_LOCK:
cek_tree = Tree(Text.assemble(("Widevine\n", "cyan"), (f"({drm.pssh.dumps()})", "text"), overflow=config.pssh_display)) cek_tree = Tree(Text.assemble(("Widevine\n", "cyan"), (f"({drm.pssh.dumps()})", "text"), overflow=config.pssh_display or "fold"))
pre_existing_tree = next( pre_existing_tree = next(
(x for x in table.columns[0].cells if isinstance(x, Tree) and x.label == cek_tree.label), None (x for x in table.columns[0].cells if isinstance(x, Tree) and x.label == cek_tree.label), None
) )
@@ -1368,7 +1368,7 @@ class dl:
Text.assemble( Text.assemble(
("PlayReady", "cyan"), ("PlayReady", "cyan"),
(f"({drm.pssh_b64 or ''})", "text"), (f"({drm.pssh_b64 or ''})", "text"),
overflow="fold", overflow = config.pssh_display or "fold",
) )
) )
pre_existing_tree = next( pre_existing_tree = next(
+1 -1
View File
@@ -1 +1 @@
__version__ = "1.4.4" __version__ = "1.4.5"
+63 -32
View File
@@ -1,21 +1,45 @@
from __future__ import annotations from __future__ import annotations
import threading
import zlib import zlib
from datetime import datetime, timedelta from datetime import datetime, timedelta
from os import stat_result from os import stat_result
from pathlib import Path from pathlib import Path
from typing import Any, Optional, Union from typing import Any, Optional, Union
import jsonpickle import jsonpickle
import jwt import jwt
from envied.core.config import config from envied.core.config import config
EXP_T = Union[datetime, str, int, float] EXP_T = Union[datetime, str, int, float]
class Cacher: class Cacher:
"""Cacher for Services to get and set arbitrary data with expiration dates.""" """
Cacher for Services to get and set arbitrary data with expiration dates.
Multiton: one instance per (service_tag, key, version) to avoid duplicate objects
pointing at the same cache file.
"""
# --- Multiton registry ---
_instances: dict[tuple[str, Optional[str], Optional[int]], "Cacher"] = {}
_lock = threading.RLock()
def __new__(
cls,
service_tag: str,
key: Optional[str] = None,
version: Optional[int] = 1,
data: Optional[Any] = None,
expiration: Optional[datetime] = None,
):
ident = (service_tag, key, version)
with cls._lock:
inst = cls._instances.get(ident)
if inst is None:
inst = super().__new__(cls)
cls._instances[ident] = inst
return inst
def __init__( def __init__(
self, self,
@@ -25,16 +49,26 @@ class Cacher:
data: Optional[Any] = None, data: Optional[Any] = None,
expiration: Optional[datetime] = None, expiration: Optional[datetime] = None,
) -> None: ) -> None:
# Make __init__ idempotent for Multiton
if getattr(self, "_initialized", False):
return
self.service_tag = service_tag self.service_tag = service_tag
self.key = key self.key = key
self.version = version self.version = version
self.data = data or {} self.data = data or {}
self.expiration = expiration self.expiration = expiration
self._initialized = True # mark as initialized
if self.expiration and self.expired: if self.expiration and self.expired:
# if its expired, remove the data for safety and delete cache file # if it's expired, remove the data for safety and delete cache file
self.data = None self.data = None
# Guard: key might be None; only unlink if there is a concrete file path
try:
self.path.unlink() self.path.unlink()
except Exception:
pass
def __bool__(self) -> bool: def __bool__(self) -> bool:
return bool(self.data) return bool(self.data)
@@ -42,20 +76,27 @@ class Cacher:
@property @property
def path(self) -> Path: def path(self) -> Path:
"""Get the path at which the cache will be read and written.""" """Get the path at which the cache will be read and written."""
# Guard against None key (e.g., before 'get' is called)
if self.key is None:
# Create a directory path to the service cache area (no file yet)
return (config.directories.cache / self.service_tag / "__unbound__").with_suffix(".json")
return (config.directories.cache / self.service_tag / self.key).with_suffix(".json") return (config.directories.cache / self.service_tag / self.key).with_suffix(".json")
@property @property
def expired(self) -> bool: def expired(self) -> bool:
return self.expiration and self.expiration < datetime.now() return bool(self.expiration and self.expiration < datetime.now())
def get(self, key: str, version: int = 1) -> Cacher: def get(self, key: str, version: int = 1) -> "Cacher":
""" """
Get Cached data for the Service by Key. Get Cached data for the Service by Key.
:param key: the filename to save the data to, should be url-safe. :param key: the filename to save the data to, should be url-safe.
:param version: the config data version you expect to use. :param version: the config data version you expect to use.
:returns: Cache object containing the cached data or None if the file does not exist. :returns: Cache object containing the cached data or empty if the file does not exist.
""" """
cache = Cacher(self.service_tag, key, version) # Use the Multiton constructor; this will reuse an existing instance
# for (service_tag, key, version) if created before.
cache = type(self)(self.service_tag, key, version)
if cache.path.is_file(): if cache.path.is_file():
data = jsonpickle.loads(cache.path.read_text(encoding="utf8")) data = jsonpickle.loads(cache.path.read_text(encoding="utf8"))
payload = data.copy() payload = data.copy()
@@ -64,15 +105,23 @@ class Cacher:
calculated = zlib.crc32(jsonpickle.dumps(payload).encode("utf8")) calculated = zlib.crc32(jsonpickle.dumps(payload).encode("utf8"))
if calculated != checksum: if calculated != checksum:
raise ValueError( raise ValueError(
f"The checksum of the Cache payload mismatched. Checksum: {checksum} !== Calculated: {calculated}" f"The checksum of the Cache payload mismatched. "
f"Checksum: {checksum} !== Calculated: {calculated}"
) )
cache.data = data["data"] cache.data = data["data"]
cache.expiration = data["expiration"] cache.expiration = data["expiration"]
cache.version = data["version"] cache.version = data["version"]
if cache.version != version: if cache.version != version:
raise ValueError( raise ValueError(
f"The version of your {self.service_tag} {key} cache is outdated. Please delete: {cache.path}" f"The version of your {self.service_tag} {key} cache is outdated. "
f"Please delete: {cache.path}"
) )
else:
# Ensure empty state if file absent
cache.data = {}
cache.expiration = None
cache.version = version
return cache return cache
def set(self, data: Any, expiration: Optional[EXP_T] = None) -> Any: def set(self, data: Any, expiration: Optional[EXP_T] = None) -> Any:
@@ -90,6 +139,9 @@ class Cacher:
expiration = jwt.decode(self.data, options={"verify_signature": False})["exp"] expiration = jwt.decode(self.data, options={"verify_signature": False})["exp"]
except jwt.DecodeError: except jwt.DecodeError:
pass pass
except Exception:
# data may not be a JWT-encoded object; ignore
pass
self.expiration = self._resolve_datetime(expiration) if expiration else None self.expiration = self._resolve_datetime(expiration) if expiration else None
@@ -112,26 +164,6 @@ class Cacher:
def _resolve_datetime(timestamp: EXP_T) -> datetime: def _resolve_datetime(timestamp: EXP_T) -> datetime:
""" """
Resolve multiple formats of a Datetime or Timestamp to an absolute Datetime. Resolve multiple formats of a Datetime or Timestamp to an absolute Datetime.
Examples:
>>> now = datetime.now()
datetime.datetime(2022, 6, 27, 9, 49, 13, 657208)
>>> iso8601 = now.isoformat()
'2022-06-27T09:49:13.657208'
>>> Cacher._resolve_datetime(iso8601)
datetime.datetime(2022, 6, 27, 9, 49, 13, 657208)
>>> Cacher._resolve_datetime(iso8601 + "Z")
datetime.datetime(2022, 6, 27, 9, 49, 13, 657208)
>>> Cacher._resolve_datetime(3600)
datetime.datetime(2022, 6, 27, 10, 52, 50, 657208)
>>> Cacher._resolve_datetime('3600')
datetime.datetime(2022, 6, 27, 10, 52, 51, 657208)
>>> Cacher._resolve_datetime(7800.113)
datetime.datetime(2022, 6, 27, 11, 59, 13, 770208)
In the int/float examples you may notice that it did not return now + 3600 seconds
but rather something a bit more than that. This is because it did not resolve 3600
seconds from the `now` variable but from right now as the function was called.
""" """
if isinstance(timestamp, datetime): if isinstance(timestamp, datetime):
return timestamp return timestamp
@@ -150,7 +182,6 @@ class Cacher:
except ValueError: except ValueError:
raise ValueError(f"Unrecognized Timestamp value {timestamp!r}") raise ValueError(f"Unrecognized Timestamp value {timestamp!r}")
if timestamp < datetime.now(): if timestamp < datetime.now():
# timestamp is likely an amount of seconds til expiration # Likely an amount of seconds until expiration
# or, it's an already expired timestamp which is unlikely
timestamp = timestamp + timedelta(seconds=datetime.now().timestamp()) timestamp = timestamp + timedelta(seconds=datetime.now().timestamp())
return timestamp return timestamp