diff --git a/CONFIG.md b/CONFIG.md index 12eb6a1..b5f5cb6 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -67,25 +67,30 @@ DSNP: default: chromecdm_903_l3 ``` -## credentials (dict) +## credentials (dict[str, str|list|dict]) -Specify login credentials to use for each Service by Profile as Key (case-sensitive). - -The value should be `email:password` or `username:password` (with some exceptions). -The first section does not have to be an email or username. It may also be a Phone number. +Specify login credentials to use for each Service, and optionally per-profile. For example, ```yaml -AMZN: +ALL4: jane@gmail.com:LoremIpsum100 # directly +AMZN: # or per-profile, optionally with a default + default: jane@example.tld:LoremIpsum99 # <-- used by default if -p/--profile is not used james: james@gmail.com:TheFriend97 - jane: jane@example.tld:LoremIpsum99 john: john@example.tld:LoremIpsum98 -NF: +NF: # the `default` key is not necessary, but no credential will be used by default john: john@gmail.com:TheGuyWhoPaysForTheNetflix69420 ``` -Credentials must be specified per-profile. You cannot specify a fallback or default credential. +The value should be in string form, i.e. `john@gmail.com:password123` or `john:password123`. +Any arbitrary values can be used on the left (username/password/phone) and right (password/secret). +You can also specify these in list form, i.e., `["john@gmail.com", ":PasswordWithAColon"]`. + +If you specify multiple credentials with keys like the `AMZN` and `NF` example above, then you should +use a `default` key or no credential will be loaded automatically unless you use `-p/--profile`. You +do not have to use a `default` key at all. + Please be aware that this information is sensitive and to keep it safe. Do not share your config. ## curl_impersonate (dict) @@ -260,34 +265,6 @@ together. - `set_title` Set the container title to `Show SXXEXX Episode Name` or `Movie (Year)`. Default: `true` -## profiles (dict) - -Pre-define Profiles to use Per-Service. - -For example, - -```yaml -AMZN: jane -DSNP: john -``` - -You can also specify a fallback value to pre-define if a match was not made. -This can be done using `default` key. This can help reduce redundancy in your specifications. - -```yaml -AMZN: jane -DSNP: john -default: james -``` - -If a Service doesn't require a profile (as it does not require Credentials or Authorization of any kind), you can -disable the profile checks by specifying `false` as the profile for the Service. - -```yaml -ALL4: false -CTV: false -``` - ## proxy_providers (dict) Enable external proxy provider services. diff --git a/README.md b/README.md index 3bbcd75..1ab9756 100644 --- a/README.md +++ b/README.md @@ -252,22 +252,33 @@ sure that the version of devine you have locally is supported by the Service cod > automatically download. Python importing the files triggers the download to begin. However, it may cause a delay on > startup. -## Profiles (Cookies & Credentials) +## Cookies & Credentials -Just like a streaming service, devine associates both a cookie and/or credential as a Profile. You can associate up to -one cookie and one credential per-profile, depending on which (or both) are needed by the Service. This system allows -you to configure multiple accounts per-service and choose which to use at any time. +Devine can authenticate with Services using Cookies and/or Credentials. Credentials are stored in the config, and +Cookies are stored in the data directory which can be found by running `devine env info`. -Credentials are stored in the config, and Cookies are stored in the data directory. You can find the location of these -by running `devine env info`. However, you can manage profiles with `devine auth --help`. E.g. to add a new John -profile to Netflix with a Cookie and Credential, take a look at the following CLI call, -`devine auth add John NF --cookie "C:\Users\John\Downloads\netflix.com.txt --credential "john@gmail.com:pass123"` +To add a Credential to a Service, take a look at the [Credentials Config](CONFIG.md#credentials-dictstr-strlistdict) +for information on setting up one or more credentials per-service. You can add one or more Credential per-service and +use `-p/--profile` to choose which Credential to use. -You can also delete a credential with `devine auth delete`. E.g., to delete the cookie for John that we just added, run -`devine auth delete John --cookie`. Take a look at `devine auth delete --help` for more information. +To add a Cookie to a Service, use a Cookie file extension to make a `cookies.txt` file and move it into the Cookies +directory. You must rename the `cookies.txt` file to that of the Service tag (case-sensitive), e.g., `NF.txt`. You can +also place it in a Service Cookie folder, e.g., `/Cookies/NF/default.txt` or `/Cookies/NF/.txt`. -> __Note__ Profile names are case-sensitive and unique per-service. They also have no arbitrary character or length -> limit, but for convenience I don't recommend using any special characters as your terminal may get confused. +You can add multiple Cookies to the `/Cookies/NF/` folder with their own unique name and then use `-p/--profile` to +choose which one to use. E.g., `/Cookies/NF/sam.txt` and then use it with `--profile sam`. If you make a Service Cookie +folder without a `.txt` or `default.txt`, but with another file, then no Cookies will be loaded unless you use +`-p/--profile` like shown. This allows you to opt in to authentication at whim. + +> [!TIP] +> - If your Service does not require Authentication, then do not define any Credential or Cookie for that Service. +> - You can use both Cookies and Credentials at the same time, so long as your Service takes and uses both. +> - If you are using profiles, then make sure you use the same name on the Credential name and Cookie file name when +> using `-p/--profile`. + +> [!WARNING] +> Profile names are case-sensitive and unique per-service. They have no arbitrary character or length limit, but for +> convenience sake I don't recommend using any special characters as your terminal may get confused. ### Cookie file format and Extensions diff --git a/devine/commands/auth.py b/devine/commands/auth.py deleted file mode 100644 index e73b187..0000000 --- a/devine/commands/auth.py +++ /dev/null @@ -1,266 +0,0 @@ -import logging -import shutil -import sys -import tkinter.filedialog -from collections import defaultdict -from pathlib import Path -from typing import Optional - -import click -from ruamel.yaml import YAML - -from devine.core.config import Config, config -from devine.core.constants import context_settings -from devine.core.credential import Credential - - -@click.group( - short_help="Manage cookies and credentials for profiles of services.", - context_settings=context_settings) -@click.pass_context -def auth(ctx: click.Context) -> None: - """Manage cookies and credentials for profiles of services.""" - ctx.obj = logging.getLogger("auth") - - -@auth.command( - name="list", - short_help="List profiles and their state for a service or all services.", - context_settings=context_settings) -@click.argument("service", type=str, required=False) -@click.pass_context -def list_(ctx: click.Context, service: Optional[str] = None) -> None: - """ - List profiles and their state for a service or all services. - - \b - Profile and Service names are case-insensitive. - """ - log = ctx.obj - service_f = service - - auth_data: dict[str, dict[str, list]] = defaultdict(lambda: defaultdict(list)) - - if config.directories.cookies.exists(): - for cookie_dir in config.directories.cookies.iterdir(): - service = cookie_dir.name - for cookie in cookie_dir.glob("*.txt"): - if cookie.stem not in auth_data[service]: - auth_data[service][cookie.stem].append("Cookie") - - for service, credentials in config.credentials.items(): - for profile in credentials: - auth_data[service][profile].append("Credential") - - for service, profiles in dict(sorted(auth_data.items())).items(): # type:ignore - if service_f and service != service_f.upper(): - continue - log.info(service) - for profile, authorizations in dict(sorted(profiles.items())).items(): - log.info(f' "{profile}": {", ".join(authorizations)}') - - -@auth.command( - short_help="View profile cookies and credentials for a service.", - context_settings=context_settings) -@click.argument("profile", type=str) -@click.argument("service", type=str) -@click.pass_context -def view(ctx: click.Context, profile: str, service: str) -> None: - """ - View profile cookies and credentials for a service. - - \b - Profile and Service names are case-sensitive. - """ - log = ctx.obj - service_f = service - profile_f = profile - found = False - - for cookie_dir in config.directories.cookies.iterdir(): - if cookie_dir.name == service_f: - for cookie in cookie_dir.glob("*.txt"): - if cookie.stem == profile_f: - log.info(f"Cookie: {cookie}") - log.debug(cookie.read_text(encoding="utf8").strip()) - found = True - break - - for service, credentials in config.credentials.items(): - if service == service_f: - for profile, credential in credentials.items(): - if profile == profile_f: - log.info(f"Credential: {':'.join(list(credential))}") - found = True - break - - if not found: - raise click.ClickException( - f"Could not find Profile '{profile_f}' for Service '{service_f}'." - f"\nThe profile and service values are case-sensitive." - ) - - -@auth.command( - short_help="Check what profile is used by services.", - context_settings=context_settings) -@click.argument("service", type=str, required=False) -@click.pass_context -def status(ctx: click.Context, service: Optional[str] = None) -> None: - """ - Check what profile is used by services. - - \b - Service names are case-sensitive. - """ - log = ctx.obj - found_profile = False - for service_, profile in config.profiles.items(): - if not service or service_.upper() == service.upper(): - log.info(f"{service_}: {profile or '--'}") - found_profile = True - - if not found_profile: - log.info(f"No profile has been explicitly set for {service}") - - default = config.profiles.get("default", "not set") - log.info(f"The default profile is {default}") - - -@auth.command( - short_help="Delete a profile and all of its authorization from a service.", - context_settings=context_settings) -@click.argument("profile", type=str) -@click.argument("service", type=str) -@click.option("--cookie", is_flag=True, default=False, help="Only delete the cookie.") -@click.option("--credential", is_flag=True, default=False, help="Only delete the credential.") -@click.pass_context -def delete(ctx: click.Context, profile: str, service: str, cookie: bool, credential: bool): - """ - Delete a profile and all of its authorization from a service. - - \b - By default this does remove both Cookies and Credentials. - You may remove only one of them with --cookie or --credential. - - \b - Profile and Service names are case-sensitive. - Comments may be removed from config! - """ - log = ctx.obj - service_f = service - profile_f = profile - found = False - - if not credential: - for cookie_dir in config.directories.cookies.iterdir(): - if cookie_dir.name == service_f: - for cookie_ in cookie_dir.glob("*.txt"): - if cookie_.stem == profile_f: - cookie_.unlink() - log.info(f"Deleted Cookie: {cookie_}") - found = True - break - - if not cookie: - for key, credentials in config.credentials.items(): - if key == service_f: - for profile, credential_ in credentials.items(): - if profile == profile_f: - config_path = Config._Directories.user_configs / Config._Filenames.root_config - yaml, data = YAML(), None - yaml.default_flow_style = False - data = yaml.load(config_path) - del data["credentials"][key][profile_f] - yaml.dump(data, config_path) - log.info(f"Deleted Credential: {credential_}") - found = True - break - - if not found: - raise click.ClickException( - f"Could not find Profile '{profile_f}' for Service '{service_f}'." - f"\nThe profile and service values are case-sensitive." - ) - - -@auth.command( - short_help="Add a Credential and/or Cookies to an existing or new profile for a service.", - context_settings=context_settings) -@click.argument("profile", type=str) -@click.argument("service", type=str) -@click.option("--cookie", type=str, default=None, help="Direct path to Cookies to add.") -@click.option("--credential", type=str, default=None, help="Direct Credential string to add.") -@click.pass_context -def add(ctx: click.Context, profile: str, service: str, cookie: Optional[str] = None, credential: Optional[str] = None): - """ - Add a Credential and/or Cookies to an existing or new profile for a service. - - \b - Cancel the Open File dialogue when presented if you do not wish to provide - cookies. The Credential should be in `Username:Password` form. The username - may be an email. If you do not wish to add a Credential, just hit enter. - - \b - Profile and Service names are case-sensitive! - Comments may be removed from config! - """ - log = ctx.obj - service = service.upper() - profile = profile.lower() - - if cookie: - cookie = Path(cookie) - if not cookie.is_file(): - log.error(f"No such file or directory: {cookie}.") - sys.exit(1) - else: - print("Opening File Dialogue, select a Cookie file to import.") - cookie = tkinter.filedialog.askopenfilename( - title="Select a Cookie file (Cancel to skip)", - filetypes=[("Cookies", "*.txt"), ("All files", "*.*")] - ) - if cookie: - cookie = Path(cookie) - else: - log.info("Skipped adding a Cookie...") - - if credential: - try: - credential = Credential.loads(credential) - except ValueError as e: - raise click.ClickException(str(e)) - else: - credential = input("Credential: ") - if credential: - try: - credential = Credential.loads(credential) - except ValueError as e: - raise click.ClickException(str(e)) - else: - log.info("Skipped adding a Credential...") - - if cookie: - final_path = (config.directories.cookies / service / profile).with_suffix(".txt") - final_path.parent.mkdir(parents=True, exist_ok=True) - if final_path.exists(): - log.error(f"A Cookie file for the Profile {profile} on {service} already exists.") - sys.exit(1) - shutil.move(cookie, final_path) - log.info(f"Moved Cookie file to: {final_path}") - - if credential: - config_path = Config._Directories.user_configs / Config._Filenames.root_config - yaml, data = YAML(), None - yaml.default_flow_style = False - data = yaml.load(config_path) - if not data: - data = {} - if "credentials" not in data: - data["credentials"] = {} - if service not in data["credentials"]: - data["credentials"][service] = {} - data["credentials"][service][profile] = credential.dumps() - yaml.dump(data, config_path) - log.info(f"Added Credential: {credential}") diff --git a/devine/commands/dl.py b/devine/commands/dl.py index a8187a6..a12f8bd 100644 --- a/devine/commands/dl.py +++ b/devine/commands/dl.py @@ -28,6 +28,7 @@ from pymediainfo import MediaInfo from pywidevine.cdm import Cdm as WidevineCdm from pywidevine.device import Device from pywidevine.remotecdm import RemoteCdm +from requests.cookies import RequestsCookieJar from rich.console import Group from rich.live import Live from rich.padding import Padding @@ -68,7 +69,7 @@ class dl: token_normalize_func=Services.get_tag )) @click.option("-p", "--profile", type=str, default=None, - help="Profile to use for Credentials and Cookies (if available). Overrides profile set by config.") + help="Profile to use for Credentials and Cookies (if available).") @click.option("-q", "--quality", type=QUALITY_LIST, default=[], help="Download Resolution(s), defaults to the best available resolution.") @click.option("-v", "--vcodec", type=click.Choice(Video.Codec, case_sensitive=False), @@ -155,17 +156,14 @@ class dl: self.log = logging.getLogger("download") self.service = Services.get_tag(ctx.invoked_subcommand) + self.profile = profile - with console.status("Preparing Service and Profile Authentication...", spinner="dots"): - if profile: - self.profile = profile - self.log.info(f"Profile: '{self.profile}' from the --profile argument") - else: - self.profile = self.get_profile(self.service) - self.log.info(f"Profile: '{self.profile}' from the config") + if self.profile: + self.log.info(f"Using profile: '{self.profile}'") + with console.status("Loading Service Config...", spinner="dots"): service_config_path = Services.get_path(self.service) / config.filenames.config - if service_config_path.is_file(): + if service_config_path.exists(): self.service_config = yaml.safe_load(service_config_path.read_text(encoding="utf8")) self.log.info("Service Config loaded") else: @@ -289,14 +287,11 @@ class dl: else: vaults_only = not cdm_only - if self.profile: - with console.status("Authenticating with Service...", spinner="dots"): - cookies = self.get_cookie_jar(self.service, self.profile) - credential = self.get_credentials(self.service, self.profile) - if not cookies and not credential: - self.log.error(f"The Profile '{self.profile}' has no Cookies or Credentials, Check for typos") - sys.exit(1) - service.authenticate(cookies, credential) + with console.status("Authenticating with Service...", spinner="dots"): + cookies = self.get_cookie_jar(self.service, self.profile) + credential = self.get_credentials(self.service, self.profile) + service.authenticate(cookies, credential) + if cookies or credential: self.log.info("Authenticated with Service") with console.status("Fetching Title Metadata...", spinner="dots"): @@ -663,13 +658,9 @@ class dl: )) # update cookies - cookie_file = config.directories.cookies / service.__class__.__name__ / f"{self.profile}.txt" + cookie_file = self.get_cookie_path(self.service, self.profile) if cookie_file.exists(): - cookie_jar = MozillaCookieJar(cookie_file) - cookie_jar.load() - for cookie in service.session.cookies: - cookie_jar.set_cookie(cookie) - cookie_jar.save(ignore_discard=True) + self.save_cookies(cookie_file, service.session.cookies) dl_time = time_elapsed_since(start_time) @@ -954,10 +945,24 @@ class dl: return profile @staticmethod - def get_cookie_jar(service: str, profile: str) -> Optional[MozillaCookieJar]: - """Get Profile's Cookies as Mozilla Cookie Jar if available.""" - cookie_file = config.directories.cookies / service / f"{profile}.txt" - if cookie_file.is_file(): + def get_cookie_path(service: str, profile: Optional[str]) -> Optional[Path]: + """Get Service Cookie File Path for Profile.""" + direct_cookie_file = config.directories.cookies / f"{service}.txt" + profile_cookie_file = config.directories.cookies / service / f"{profile}.txt" + default_cookie_file = config.directories.cookies / service / "default.txt" + + if direct_cookie_file.exists(): + return direct_cookie_file + elif profile_cookie_file.exists(): + return profile_cookie_file + elif default_cookie_file.exists(): + return default_cookie_file + + @staticmethod + def get_cookie_jar(service: str, profile: Optional[str]) -> Optional[MozillaCookieJar]: + """Get Service Cookies for Profile.""" + cookie_file = dl.get_cookie_path(service, profile) + if cookie_file: cookie_jar = MozillaCookieJar(cookie_file) cookie_data = html.unescape(cookie_file.read_text("utf8")).splitlines(keepends=False) for i, line in enumerate(cookie_data): @@ -972,17 +977,29 @@ class dl: cookie_file.write_text(cookie_data, "utf8") cookie_jar.load(ignore_discard=True, ignore_expires=True) return cookie_jar - return None @staticmethod - def get_credentials(service: str, profile: str) -> Optional[Credential]: - """Get Profile's Credential if available.""" - cred = config.credentials.get(service, {}).get(profile) - if cred: - if isinstance(cred, list): - return Credential(*cred) - return Credential.loads(cred) - return None + def save_cookies(path: Path, cookies: RequestsCookieJar): + cookie_jar = MozillaCookieJar(path) + cookie_jar.load() + for cookie in cookies: + cookie_jar.set_cookie(cookie) + cookie_jar.save(ignore_discard=True) + + @staticmethod + def get_credentials(service: str, profile: Optional[str]) -> Optional[Credential]: + """Get Service Credentials for Profile.""" + credentials = config.credentials.get(service) + if credentials: + if isinstance(credentials, dict): + if profile: + credentials = credentials.get(profile) or credentials.get("default") + else: + credentials = credentials.get("default") + if credentials: + if isinstance(credentials, list): + return Credential(*credentials) + return Credential.loads(credentials) # type: ignore @staticmethod def get_cdm(service: str, profile: Optional[str] = None) -> WidevineCdm: diff --git a/devine/core/config.py b/devine/core/config.py index 9b09f8a..226a3a2 100644 --- a/devine/core/config.py +++ b/devine/core/config.py @@ -60,7 +60,6 @@ class Config: self.key_vaults: list[dict[str, Any]] = kwargs.get("key_vaults", []) self.muxing: dict = kwargs.get("muxing") or {} self.nordvpn: dict = kwargs.get("nordvpn") or {} - self.profiles: dict = kwargs.get("profiles") or {} self.proxy_providers: dict = kwargs.get("proxy_providers") or {} self.serve: dict = kwargs.get("serve") or {} self.services: dict = kwargs.get("services") or {}