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
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.
+2 -2
View File
@@ -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(
+1 -1
View File
@@ -1 +1 @@
__version__ = "1.4.4"
__version__ = "1.4.5"
+64 -33
View File
@@ -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