mirror of
https://github.com/vinefeeder/TwinVine.git
synced 2026-07-15 18:10:04 +02:00
updates 1.4.5
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
__version__ = "1.4.4"
|
__version__ = "1.4.5"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user