From 18f804a02d1082337801a94652e13fbfed58c33a Mon Sep 17 00:00:00 2001 From: VineFeeder Date: Wed, 10 Sep 2025 15:11:21 +0100 Subject: [PATCH] updates 1.4.5 --- packages/envied/README.md | 7 ++ packages/envied/src/envied/commands/dl.py | 4 +- packages/envied/src/envied/core/__init__.py | 2 +- packages/envied/src/envied/core/cacher.py | 97 ++++++++++++++------- 4 files changed, 74 insertions(+), 36 deletions(-) diff --git a/packages/envied/README.md b/packages/envied/README.md index b11d9d2..256ace6 100644 --- a/packages/envied/README.md +++ b/packages/envied/README.md @@ -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 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. diff --git a/packages/envied/src/envied/commands/dl.py b/packages/envied/src/envied/commands/dl.py index 2fdce5b..4e0f01e 100644 --- a/packages/envied/src/envied/commands/dl.py +++ b/packages/envied/src/envied/commands/dl.py @@ -1272,7 +1272,7 @@ class dl: if isinstance(drm, Widevine): 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( (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( ("PlayReady", "cyan"), (f"({drm.pssh_b64 or ''})", "text"), - overflow="fold", + overflow = config.pssh_display or "fold", ) ) pre_existing_tree = next( diff --git a/packages/envied/src/envied/core/__init__.py b/packages/envied/src/envied/core/__init__.py index c0f285b..56dadec 100644 --- a/packages/envied/src/envied/core/__init__.py +++ b/packages/envied/src/envied/core/__init__.py @@ -1 +1 @@ -__version__ = "1.4.4" +__version__ = "1.4.5" diff --git a/packages/envied/src/envied/core/cacher.py b/packages/envied/src/envied/core/cacher.py index c88b9ed..bc4d3e6 100644 --- a/packages/envied/src/envied/core/cacher.py +++ b/packages/envied/src/envied/core/cacher.py @@ -1,21 +1,45 @@ from __future__ import annotations +import threading import zlib from datetime import datetime, timedelta from os import stat_result from pathlib import Path from typing import Any, Optional, Union - import jsonpickle import jwt - from envied.core.config import config EXP_T = Union[datetime, str, int, float] 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__( self, @@ -25,16 +49,26 @@ class Cacher: data: Optional[Any] = None, expiration: Optional[datetime] = None, ) -> None: + # Make __init__ idempotent for Multiton + if getattr(self, "_initialized", False): + return + self.service_tag = service_tag self.key = key self.version = version self.data = data or {} self.expiration = expiration + self._initialized = True # mark as initialized + 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.path.unlink() + # Guard: key might be None; only unlink if there is a concrete file path + try: + self.path.unlink() + except Exception: + pass def __bool__(self) -> bool: return bool(self.data) @@ -42,20 +76,27 @@ class Cacher: @property def path(self) -> Path: """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") @property 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. :param key: the filename to save the data to, should be url-safe. :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(): data = jsonpickle.loads(cache.path.read_text(encoding="utf8")) payload = data.copy() @@ -64,15 +105,23 @@ class Cacher: calculated = zlib.crc32(jsonpickle.dumps(payload).encode("utf8")) if calculated != checksum: 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.expiration = data["expiration"] cache.version = data["version"] if cache.version != version: 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 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"] except jwt.DecodeError: pass + except Exception: + # data may not be a JWT-encoded object; ignore + pass self.expiration = self._resolve_datetime(expiration) if expiration else None @@ -112,26 +164,6 @@ class Cacher: def _resolve_datetime(timestamp: EXP_T) -> 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): return timestamp @@ -150,7 +182,6 @@ class Cacher: except ValueError: raise ValueError(f"Unrecognized Timestamp value {timestamp!r}") if timestamp < datetime.now(): - # timestamp is likely an amount of seconds til expiration - # or, it's an already expired timestamp which is unlikely + # Likely an amount of seconds until expiration timestamp = timestamp + timedelta(seconds=datetime.now().timestamp()) return timestamp