Merge pull request #203 from arabcoders/dev

Make presets more powerful
This commit is contained in:
Abdulmohsen 2025-03-07 19:01:07 +03:00 committed by GitHub
commit 7b395d717d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 561 additions and 315 deletions

81
.github/workflows/create-release.yml vendored Normal file
View file

@ -0,0 +1,81 @@
name: Create New Release
on:
workflow_dispatch:
inputs:
new_tag:
description: 'Tag name for the new release'
required: true
jobs:
create_release:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get latest release tag from GitHub
id: latest_release
uses: actions/github-script@v6
with:
script: |
try {
const latestRelease = await github.rest.repos.getLatestRelease({
owner: context.repo.owner,
repo: context.repo.repo
});
core.info(`Latest release tag: ${latestRelease.data.tag_name}`);
core.setOutput('last_release', latestRelease.data.tag_name);
} catch (error) {
core.info("No previous release found.");
// If no release exists, output an empty string.
core.setOutput('last_release', '');
}
- name: Set new release tag from input
id: new_tag
run: |
echo "NEW_TAG=${{ github.event.inputs.new_tag }}" >> $GITHUB_OUTPUT
- name: Generate commit log for new release
id: commits
run: |
LAST_RELEASE="${{ steps.latest_release.outputs.last_release }}"
NEW_TAG="${{ steps.new_tag.outputs.NEW_TAG }}"
if [ -z "$NEW_TAG" ]; then
echo "No new tag provided. Exiting."
exit 1
fi
if [ -z "${LAST_RELEASE}" ]; then
echo "No previous release found, using the repositorys initial commit as the starting point."
FIRST_COMMIT=$(git rev-list --max-parents=0 HEAD)
RANGE="${FIRST_COMMIT}..${NEW_TAG}"
else
RANGE="${LAST_RELEASE}..${NEW_TAG}"
fi
echo "Comparing commits between: ${RANGE}"
LOG=$(git log "${RANGE}" --no-merges --pretty=format:"- %h %s by %an")
echo "LOG<<EOF" >> "$GITHUB_ENV"
echo "$LOG" >> "$GITHUB_ENV"
echo "EOF" >> "$GITHUB_ENV"
# Create or update the GitHub release for the new tag.
- name: Create / Update GitHub Release for new tag
uses: softprops/action-gh-release@master
with:
tag_name: ${{ steps.new_tag.outputs.NEW_TAG }}
name: "${{ steps.new_tag.outputs.NEW_TAG }}"
body: ${{ env.LOG }}
append_body: false
generate_release_notes: true
make_latest: true
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -189,82 +189,3 @@ jobs:
with: with:
entrypoint: node entrypoint: node
args: /opt/docker-readme-sync/sync args: /opt/docker-readme-sync/sync
create_release:
needs: push-build
runs-on: ubuntu-latest
if: (endsWith(github.ref, github.event.repository.default_branch) && success()) || (github.event_name == 'workflow_dispatch' && github.event.inputs.create_release == 'true')
steps:
- name: Check out code
uses: actions/checkout@v4
with:
fetch-depth: 0 # so we can see all tags + full history
- name: Determine current branch
id: branch
run: |
# github.ref_name should be "master", "main", or your branch name
echo "BRANCH_NAME=${GITHUB_REF_NAME}" >> $GITHUB_OUTPUT
- name: Fetch the two latest tags for this branch
id: last_two_tags
run: |
git fetch --tags
BRANCH_NAME="${{ steps.branch.outputs.BRANCH_NAME }}"
echo "Current branch: $BRANCH_NAME"
# List tags matching "branchname-*" and sort by *creation date* descending
# Then pick the top 2
LATEST_TAGS=$(git tag --list "${BRANCH_NAME}-*" --sort=-creatordate | head -n 2)
TAG_COUNT=$(echo "$LATEST_TAGS" | wc -l)
echo "Found tags:"
echo "$LATEST_TAGS"
if [ "$TAG_COUNT" -lt 2 ]; then
echo "Not enough tags found (need at least 2) to compare commits."
echo "NOT_ENOUGH_TAGS=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# The first line is the newest tag
TAG_NEWEST=$(echo "$LATEST_TAGS" | sed -n '1p')
# The second line is the previous newest
TAG_PREVIOUS=$(echo "$LATEST_TAGS" | sed -n '2p')
echo "Newest tag: $TAG_NEWEST"
echo "Previous tag: $TAG_PREVIOUS"
# Expose them as outputs for next step
echo "NOT_ENOUGH_TAGS=false" >> "$GITHUB_OUTPUT"
echo "TAG_NEWEST=$TAG_NEWEST" >> "$GITHUB_OUTPUT"
echo "TAG_PREVIOUS=$TAG_PREVIOUS" >> "$GITHUB_OUTPUT"
- name: Generate commit log for newest tag
id: commits
if: steps.last_two_tags.outputs.NOT_ENOUGH_TAGS != 'true'
run: |
TAG_NEWEST="${{ steps.last_two_tags.outputs.TAG_NEWEST }}"
TAG_PREVIOUS="${{ steps.last_two_tags.outputs.TAG_PREVIOUS }}"
echo "Comparing commits between: $TAG_PREVIOUS..$TAG_NEWEST"
LOG=$(git log "$TAG_PREVIOUS".."$TAG_NEWEST" --no-merges --pretty=format:"- %h %s by %an")
echo "LOG<<EOF" >> "$GITHUB_ENV"
echo "$LOG" >> "$GITHUB_ENV"
echo "EOF" >> "$GITHUB_ENV"
- name: Create / Update GitHub Release for the newest tag
if: steps.last_two_tags.outputs.NOT_ENOUGH_TAGS != 'true'
uses: softprops/action-gh-release@master
with:
tag_name: ${{ steps.last_two_tags.outputs.TAG_NEWEST }}
name: "${{ steps.last_two_tags.outputs.TAG_NEWEST }}"
body: ${{ env.LOG }}
append_body: true
generate_release_notes: true
make_latest: true
draft: false
prerelease: false
token: ${{ secrets.GITHUB_TOKEN }}

View file

@ -14,7 +14,7 @@ from .config import Config
from .Emitter import Emitter from .Emitter import Emitter
from .ffprobe import ffprobe from .ffprobe import ffprobe
from .ItemDTO import ItemDTO from .ItemDTO import ItemDTO
from .Utils import get_opts, merge_config from .YTDLPOpts import YTDLPOpts
LOG = logging.getLogger("download") LOG = logging.getLogger("download")
@ -114,22 +114,36 @@ class Download:
def _download(self): def _download(self):
try: try:
params: dict = get_opts(self.preset, merge_config(self.default_ytdl_opts, self.ytdl_opts)) params = (
params.update( YTDLPOpts.get_instance()
{ .preset(self.preset)
"color": "no_color", .add(self.ytdl_opts, from_user=True)
"paths": {"home": self.download_dir, "temp": self.temp_path}, .add(
"outtmpl": {"default": self.template, "chapter": self.template_chapter}, {
"noprogress": True, "color": "no_color",
"break_on_existing": True, "paths": {
"progress_hooks": [self._progress_hook], "home": self.download_dir,
"postprocessor_hooks": [self._postprocessor_hook], "temp": self.temp_path,
"ignoreerrors": False, },
} "outtmpl": {
"default": self.template,
"chapter": self.template_chapter,
},
"noprogress": True,
"break_on_existing": True,
"ignoreerrors": False,
},
from_user=False,
)
.get_all()
) )
if "format" not in params and self.default_ytdl_opts.get("format", None): params.update(
params["format"] = "best" {
"progress_hooks": [self._progress_hook],
"postprocessor_hooks": [self._postprocessor_hook],
}
)
if self.debug: if self.debug:
params["verbose"] = True params["verbose"] = True
@ -137,7 +151,9 @@ class Download:
if self.info.cookies: if self.info.cookies:
try: try:
with open(os.path.join(self.temp_path, f"cookie_{self.info._id}.txt"), "w") as f: cookie_file = os.path.join(self.temp_path, f"cookie_{self.info._id}.txt")
LOG.debug(f"Creating cookie file for '{self.info.id}: {self.info.title}' - '{cookie_file}'.")
with open(cookie_file, "w") as f:
f.write(self.info.cookies) f.write(self.info.cookies)
params["cookiefile"] = f.name params["cookiefile"] = f.name
except ValueError as e: except ValueError as e:
@ -163,6 +179,11 @@ class Download:
LOG.debug("Params before passing to yt-dlp.", extra=params) LOG.debug("Params before passing to yt-dlp.", extra=params)
if "impersonate" in params:
from yt_dlp.networking.impersonate import ImpersonateTarget
params["impersonate"] = ImpersonateTarget.from_str(params["impersonate"])
cls = yt_dlp.YoutubeDL(params=params) cls = yt_dlp.YoutubeDL(params=params)
if isinstance(self.info_dict, dict) and len(self.info_dict) > 1: if isinstance(self.info_dict, dict) and len(self.info_dict) > 1:
@ -175,6 +196,7 @@ class Download:
self.status_queue.put({"id": self.id, "status": "finished" if ret == 0 else "error"}) self.status_queue.put({"id": self.id, "status": "finished" if ret == 0 else "error"})
except Exception as exc: except Exception as exc:
LOG.exception(exc)
self.status_queue.put({"id": self.id, "status": "error", "msg": str(exc), "error": str(exc)}) self.status_queue.put({"id": self.id, "status": "error", "msg": str(exc), "error": str(exc)})
LOG.info(f'Task id="{self.info.id}" PID="{os.getpid()}" title="{self.info.title}" completed.') LOG.info(f'Task id="{self.info.id}" PID="{os.getpid()}" title="{self.info.title}" completed.')

View file

@ -19,8 +19,10 @@ from .Download import Download
from .Emitter import Emitter from .Emitter import Emitter
from .EventsSubscriber import Events from .EventsSubscriber import Events
from .ItemDTO import ItemDTO from .ItemDTO import ItemDTO
from .Presets import Presets
from .Singleton import Singleton from .Singleton import Singleton
from .Utils import calc_download_path, extract_info, get_opts, is_downloaded, merge_config from .Utils import calc_download_path, extract_info, is_downloaded
from .YTDLPOpts import YTDLPOpts
LOG = logging.getLogger("DownloadQueue") LOG = logging.getLogger("DownloadQueue")
@ -352,9 +354,21 @@ class DownloadQueue(metaclass=Singleton):
template: str = "", template: str = "",
already=None, already=None,
): ):
_preset = Presets.get_instance().get(name=preset)
config = config if config else {} config = config if config else {}
folder = str(folder) if folder else "" folder = str(folder) if folder else ""
if _preset:
if _preset.folder and not folder:
folder = _preset.folder
if _preset.template and not template:
template = _preset.template
if _preset.cookies and not cookies:
cookies = _preset.cookies
filePath = calc_download_path(base_path=self.config.download_path, folder=folder) filePath = calc_download_path(base_path=self.config.download_path, folder=folder)
yt_conf = {} yt_conf = {}
cookie_file = os.path.join(self.config.temp_path, f"c_{uuid.uuid4().hex}.txt") cookie_file = os.path.join(self.config.temp_path, f"c_{uuid.uuid4().hex}.txt")
@ -395,7 +409,7 @@ class DownloadQueue(metaclass=Singleton):
"func": lambda _, msg: logs.append(msg), "func": lambda _, msg: logs.append(msg),
"level": logging.WARNING, "level": logging.WARNING,
}, },
**get_opts(preset, merge_config(self.config.ytdl_options, config)), **YTDLPOpts.get_instance().preset(name=preset).add(config=config, from_user=True).get_all(),
} }
if cookies: if cookies:

View file

@ -708,7 +708,7 @@ class HttpAPI(Common):
item["id"] = str(uuid.uuid4()) item["id"] = str(uuid.uuid4())
if not item.get("args", None) or str(item.get("args")).strip() == "": if not item.get("args", None) or str(item.get("args")).strip() == "":
item["config"] = {} item["args"] = {}
if item.get("args", None) and isinstance(item.get("args"), str): if item.get("args", None) and isinstance(item.get("args"), str):
item["args"] = json.loads(item.get("args")) item["args"] = json.loads(item.get("args"))

View file

@ -27,12 +27,21 @@ class Preset:
format: str format: str
"""The format of the preset.""" """The format of the preset."""
args: dict[str, list[str] | bool] | None = field(default_factory=dict) args: dict[str, list[str] | bool] = field(default_factory=dict)
"""The arguments of the preset.""" """The arguments of the preset."""
postprocessors: list | None = field(default_factory=list) postprocessors: list = field(default_factory=list)
"""The postprocessors of the preset.""" """The postprocessors of the preset."""
folder: str = ""
"""The default download folder to use if non is given."""
template: str = ""
"""The default template to use if non is given."""
cookies: str = ""
"""The default cookies to use if non is given."""
default: bool = False default: bool = False
def serialize(self) -> dict: def serialize(self) -> dict:

View file

@ -15,7 +15,6 @@ from typing import Any
import yt_dlp import yt_dlp
from Crypto.Cipher import AES from Crypto.Cipher import AES
from yt_dlp.networking.impersonate import ImpersonateTarget
from .LogWrapper import LogWrapper from .LogWrapper import LogWrapper
@ -31,52 +30,7 @@ class StreamingError(Exception):
"""Raised when an error occurs during streaming.""" """Raised when an error occurs during streaming."""
def get_opts(preset: str, ytdl_opts: dict) -> dict: def get_video_info(url: str, ytdlp_opts: dict | None = None, no_archive: bool = True) -> Any | dict[str, Any] | None:
"""
Returns ytdlp options download options
Args:
preset (str): the name of the preset selected.
ytdl_opts (dict): current options selected
Returns:
ytdl extra options
"""
if "format" in ytdl_opts and len(ytdl_opts["format"]) > 2:
format = ytdl_opts["format"]
LOG.info(f"Format '{format}' was given via yt-dlp options. Therefore, the preset will be ignored.")
return ytdl_opts
opts = copy.deepcopy(ytdl_opts)
if "default" == preset:
LOG.debug("Using default preset.")
return opts
from .Presets import Presets
p = Presets.get_instance().get(name=preset)
if not p:
LOG.error(f"Preset '{preset}' is not defined as preset.")
return opts
opts["format"] = p.get("format")
postprocessors = p.get("postprocessors", [])
if isinstance(postprocessors, list) and len(postprocessors) > 0:
opts["postprocessors"] = postprocessors
args = p.get("args", {})
if isinstance(args, dict) and len(args) > 0:
for key, value in args.items():
opts[key] = value
LOG.debug(f"Using preset '{preset}', altered options: {opts}")
return opts
def get_video_info(url: str, ytdlp_opts: dict = None, no_archive: bool = True) -> Any | dict[str, Any] | None:
""" """
Extracts video information from the given URL. Extracts video information from the given URL.
@ -111,6 +65,11 @@ def calc_download_path(base_path: str, folder: str | None = None, create_path: b
""" """
Calculates download path and prevents folder traversal. Calculates download path and prevents folder traversal.
Args:
base_path (str): Base download path.
folder (str): Folder to add to the base path.
create_path (bool): Create the path if it does not exist.
Returns: Returns:
Download path with base folder factored in. Download path with base folder factored in.
@ -135,29 +94,33 @@ def calc_download_path(base_path: str, folder: str | None = None, create_path: b
def extract_info(config: dict, url: str, debug: bool = False) -> dict: def extract_info(config: dict, url: str, debug: bool = False) -> dict:
"""
Extracts video information from the given URL.
Args:
config (dict): Configuration options.
url (str): URL to extract information from.
debug (bool): Enable debug logging.
Returns:
dict: Video information.
"""
log_wrapper = LogWrapper() log_wrapper = LogWrapper()
params: dict = { params: dict = {
**config,
"color": "no_color", "color": "no_color",
"extract_flat": True, "extract_flat": True,
"skip_download": True, "skip_download": True,
"ignoreerrors": True, "ignoreerrors": True,
"ignore_no_formats_error": True, "ignore_no_formats_error": True,
**config,
} }
# Remove keys that are not needed for info extraction. # Remove keys that are not needed for info extraction.
keys: list = [ keys_to_remove = [key for key in params if str(key).startswith("write") or key in ["postprocessors"]]
"writeinfojson", for key in keys_to_remove:
"writethumbnail", params.pop(key, None)
"writedescription",
"writeautomaticsub",
"postprocessors",
]
for key in keys:
if key in params:
params.pop(key)
log_wrapper.add_target(target=logging.getLogger("yt-dlp"), level=logging.DEBUG if debug else logging.WARNING) log_wrapper.add_target(target=logging.getLogger("yt-dlp"), level=logging.DEBUG if debug else logging.WARNING)
if debug: if debug:
@ -166,7 +129,6 @@ def extract_info(config: dict, url: str, debug: bool = False) -> dict:
params["quiet"] = True params["quiet"] = True
if "callback" in params: if "callback" in params:
# callback can be a function or dict with {level: level, func: target}
if isinstance(params["callback"], dict): if isinstance(params["callback"], dict):
log_wrapper.add_target( log_wrapper.add_target(
target=params["callback"]["func"], target=params["callback"]["func"],
@ -186,47 +148,56 @@ def extract_info(config: dict, url: str, debug: bool = False) -> dict:
def merge_dict(source: dict, destination: dict) -> dict: def merge_dict(source: dict, destination: dict) -> dict:
"""Merge data from source into destination""" """
Merge data from source into destination safely.
Args:
source (dict): Source data
destination (dict): Destination data
Returns:
dict: The merged dictionary
"""
if not isinstance(source, dict) or not isinstance(destination, dict):
msg = "Both source and destination must be dictionaries."
raise TypeError(msg)
destination_copy = copy.deepcopy(destination) destination_copy = copy.deepcopy(destination)
for key, value in source.items(): for key, value in source.items():
destination_key_value = destination_copy.get(key) if key in {"__class__", "__dict__", "__globals__", "__builtins__"}:
if isinstance(value, dict) and isinstance(destination_key_value, dict): continue
destination_copy[key] = merge_dict(source=value, destination=destination_copy.setdefault(key, {}))
elif isinstance(value, list) and isinstance(destination_key_value, list): destination_value = destination_copy.get(key)
destination_copy[key] = destination_key_value + value
# Recursively merge dictionaries
if isinstance(value, dict) and isinstance(destination_value, dict):
destination_copy[key] = merge_dict(value, destination_value)
# Safely extend lists without reference issues
elif isinstance(value, list) and isinstance(destination_value, list):
destination_copy[key] = copy.deepcopy(destination_value) + copy.deepcopy(value)
else: else:
destination_copy[key] = value destination_copy[key] = copy.deepcopy(value)
return destination_copy return destination_copy
def merge_config(config: dict, new_config: dict) -> dict: def is_downloaded(archive_file: str, url: str) -> tuple[bool, dict[str | None, str | None, str | None]]:
""" """
Merge user provided config into default config Check if the video is already downloaded.
Args: Args:
config (dict): Default config archive_file (str): Archive file path.
new_config (dict): User provided config url (str): URL to check.
Returns: Returns:
dict: Merged config bool: True if the video is already downloaded.
dict: Video information.
""" """
for key in IGNORED_KEYS:
if key in new_config:
LOG.error(f"Key '{key}' is not allowed to be manually set via config.")
del new_config[key]
conf = merge_dict(new_config, config)
if "impersonate" in conf:
conf["impersonate"] = ImpersonateTarget.from_str(conf["impersonate"])
return conf
def is_downloaded(archive_file: str, url: str) -> tuple[bool, dict[str | None, str | None, str | None]]:
global YTDLP_INFO_CLS # noqa: PLW0603 global YTDLP_INFO_CLS # noqa: PLW0603
idDict = { idDict = {
@ -301,11 +272,7 @@ def load_file(file: str, check_type=None) -> tuple[dict | list, bool, str]:
if check_type: if check_type:
assert isinstance(opts, check_type) # noqa: S101 assert isinstance(opts, check_type) # noqa: S101
return ( return (opts, True, "")
opts,
True,
"",
)
except Exception: except Exception:
with open(file) as json_data: with open(file) as json_data:
from pyjson5 import load as json5_load from pyjson5 import load as json5_load
@ -316,23 +283,11 @@ def load_file(file: str, check_type=None) -> tuple[dict | list, bool, str]:
if check_type: if check_type:
assert isinstance(opts, check_type) # noqa: S101 assert isinstance(opts, check_type) # noqa: S101
return ( return (opts, True, "")
opts,
True,
"",
)
except AssertionError: except AssertionError:
return ( return ({}, False, f"Failed to assert that the contents '{type(opts)}' are of type '{check_type}'.")
{},
False,
f"Failed to assert that the contents '{type(opts)}' are of type '{check_type}'.",
)
except Exception as e: except Exception as e:
return ( return ({}, False, f"{e}")
{},
False,
f"{e}",
)
def check_id(file: pathlib.Path) -> bool | str: def check_id(file: pathlib.Path) -> bool | str:
@ -340,10 +295,12 @@ def check_id(file: pathlib.Path) -> bool | str:
Check if we are able to get an id from the file name. Check if we are able to get an id from the file name.
if so check if any video file with the same id exists. if so check if any video file with the same id exists.
:param basePath: Base path to strip. Args:
:param file: File to check. file (pathlib.Path): File to check.
Returns:
bool|str: False if no file found, else the file path.
:return: False if no id found, otherwise the id.
""" """
match = re.search(r"(?<=\[)(?:youtube-)?(?P<id>[a-zA-Z0-9\-_]{11})(?=\])", file.stem, re.IGNORECASE) match = re.search(r"(?<=\[)(?:youtube-)?(?P<id>[a-zA-Z0-9\-_]{11})(?=\])", file.stem, re.IGNORECASE)
if not match: if not match:
@ -371,14 +328,18 @@ def ag(array: dict | list, path: list[str | int] | str | int, default: Any = Non
""" """
dict/array getter: Retrieve a value from a nested dict or object using a path. dict/array getter: Retrieve a value from a nested dict or object using a path.
:param array_or_object: dict-like or object from which to retrieve values Args:
:param path: string, list, or None. Represents the path to retrieve: array (dict|list): dict-like or object from which to retrieve values.
- If None or empty string, returns the entire structure. path (list|str|int): Represents the path to retrieve:
- If list, tries each path and returns the first found. - If None or empty string, returns the entire structure.
- If string, navigates through nested dict keys separated by `separator`. - If list, tries each path and returns the first found.
:param default: Value (or callable) returned if nothing is found. - If string, navigates through nested dict keys separated by `separator`.
:param separator: Separator for nested paths in strings. default (Any): Value (or callable) returned if nothing is found.
:return: The found value or the default if not found. separator (str): Separator for nested paths in strings.
Returns:
Any: The found value or the default if not found.
""" """
if path is None or path == "": if path is None or path == "":
return array return array
@ -523,8 +484,12 @@ def get_sidecar_subtitles(file: pathlib.Path) -> list[dict]:
""" """
Get sidecar files for the given file. Get sidecar files for the given file.
:param file: File to get sidecar files for. Args:
:return: List of sidecar files. file (pathlib.Path): The video file.
Returns:
list: List of sidecar files.
""" """
files = [] files = []

132
app/library/YTDLPOpts.py Normal file
View file

@ -0,0 +1,132 @@
import logging
from pathlib import Path
from .config import Config
from .Presets import Presets
from .Singleton import Singleton
from .Utils import IGNORED_KEYS, calc_download_path, merge_dict
LOG = logging.getLogger("YTDLPOpts")
class YTDLPOpts(metaclass=Singleton):
_item_opts: dict = {}
"""The item options."""
_preset_opts: dict = {}
"""The preset options."""
_instance = None
"""The instance of the class."""
def __init__(self):
self._config = Config.get_instance()
@staticmethod
def get_instance() -> "YTDLPOpts":
"""
Get the instance of the class.
Returns:
Presets: The instance of the class
"""
if not YTDLPOpts._instance:
YTDLPOpts._instance = YTDLPOpts()
return YTDLPOpts._instance
def add(self, config: dict, from_user: bool = False) -> "YTDLPOpts":
"""
Add the options to the item options.
Args:
config (dict): The options to add
from_user (bool): If the options are from the user
Returns:
YTDLPOpts: The instance of the class
"""
for key, value in config.items():
if key in IGNORED_KEYS and from_user:
continue
self._item_opts[key] = value
return self
def preset(self, name: str, with_cookies: bool = False) -> "YTDLPOpts":
"""
Add the preset options to the item options.
Args:
name (str): The name of the preset
with_cookies (bool): If the cookies should be added
Returns:
YTDLPOpts: The instance of the class
"""
preset = Presets.get_instance().get(name=name)
if not preset or "default" == name:
return self
if preset.cookies and with_cookies:
file = Path(self._config.config_path, "cookies", f"{preset.id}.txt")
if not file.parent.exists():
file.parent.mkdir(parents=True)
with open(file, "w") as f:
f.write(preset.cookies)
self._preset_opts["cookiefile"] = str(file)
if preset.format:
self._preset_opts["format"] = preset.format
if preset.template:
self._preset_opts["outtmpl"] = {"default": preset.template, "chapter": self._config.output_template_chapter}
if preset.folder:
self._preset_opts["paths"] = {
"home": calc_download_path(base_path=self._config.download_path, folder=preset.folder),
"temp": self._config.temp_path,
}
if preset.postprocessors and isinstance(preset.postprocessors, list) and len(preset.postprocessors) > 0:
self._preset_opts["postprocessors"] = preset.postprocessors
if preset.args and isinstance(preset.args, dict) and len(preset.args) > 0:
for key, value in preset.args.items():
if key in IGNORED_KEYS:
continue
self._preset_opts[key] = value
return self
def get_all(self, keep: bool = False) -> dict:
"""
Get all the options.
Args:
keep (bool): If the options should be kept
Returns:
dict: The options
"""
default_opts = self._config.ytdl_options
default_opts["paths"] = {"home": self._config.download_path, "temp": self._config.temp_path}
default_opts["outtmpl"] = {
"default": self._config.output_template,
"chapter": self._config.output_template_chapter,
}
data = merge_dict(self._item_opts, merge_dict(self._preset_opts, default_opts))
if not keep:
self.presets_opts = {}
self._item_opts = {}
return data

View file

@ -3,12 +3,18 @@
"id": "3e163c6c-64eb-4448-924f-814b629b3810", "id": "3e163c6c-64eb-4448-924f-814b629b3810",
"name": "default", "name": "default",
"format": "default", "format": "default",
"folder": "",
"template": "",
"cookies": "",
"default": true "default": true
}, },
{ {
"id": "5bf9c42b-8852-468a-99f5-915622dfba25", "id": "5bf9c42b-8852-468a-99f5-915622dfba25",
"name": "Best video and audio", "name": "Best video and audio",
"format": "bv+ba/b", "format": "bv+ba/b",
"folder": "",
"template": "",
"cookies": "",
"default": true "default": true
}, },
{ {
@ -20,6 +26,9 @@
"vcodec:h264" "vcodec:h264"
] ]
}, },
"folder": "",
"template": "",
"cookies": "",
"default": true "default": true
}, },
{ {
@ -31,6 +40,9 @@
"vcodec:h264" "vcodec:h264"
] ]
}, },
"folder": "",
"template": "",
"cookies": "",
"default": true "default": true
}, },
{ {
@ -63,6 +75,9 @@
"when": "playlist" "when": "playlist"
} }
], ],
"folder": "",
"template": "",
"cookies": "",
"default": true "default": true
} }
] ]

View file

@ -221,7 +221,8 @@ hr {
padding-top: 0.5em; padding-top: 0.5em;
} }
.play-overlay, .is-pointer { .play-overlay,
.is-pointer {
cursor: pointer; cursor: pointer;
} }
@ -254,3 +255,7 @@ hr {
object-fit: cover; object-fit: cover;
object-position: top; object-position: top;
} }
.is-pre {
white-space: pre;
}

View file

@ -107,8 +107,8 @@
Cookies Cookies
</label> </label>
<div class="control"> <div class="control">
<textarea class="textarea" id="ytdlpCookies" v-model="ytdlpCookies" <textarea class="textarea is-pre" id="ytdlpCookies" v-model="ytdlpCookies"
:disabled="!socket.isConnected || addInProgress"></textarea> :disabled="!socket.isConnected || addInProgress" />
</div> </div>
<span class="help"> <span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>

View file

@ -75,100 +75,162 @@
<div class="column is-12"> <div class="column is-12">
<form id="presetForm" @submit.prevent="checkInfo()"> <form id="presetForm" @submit.prevent="checkInfo()">
<div class="box"> <div class="card">
<div class="columns is-multiline is-mobile"> <div class="card-header">
<div class="column is-12"> <div class="card-header-title">
<h1 class="title is-6" style="border-bottom: 1px solid #dbdbdb;"> <span class="icon-text">
<span class="icon-text"> <span class="icon"><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'" /></span>
<span class="icon"><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'" /></span> <span>{{ reference ? 'Edit' : 'Add' }}</span>
<span>{{ reference ? 'Edit' : 'Add' }}</span> </span>
</span>
</h1>
</div> </div>
</div>
<div class="column is-6-tablet is-12-mobile"> <div class="card-content">
<div class="field"> <div class="columns is-multiline is-mobile">
<label class="label is-inline" for="name" v-text="'Name'" /> <div class="column is-6-tablet is-12-mobile">
<div class="control has-icons-left"> <div class="field">
<input type="text" class="input" id="name" v-model="form.name" :disabled="addInProgress"> <label class="label is-inline" for="name" v-text="'Name'" />
<span class="icon is-small is-left"><i class="fa-solid fa-n" /></span> <div class="control has-icons-left">
</div> <input type="text" class="input" id="name" v-model="form.name" :disabled="addInProgress">
<span class="help"> <span class="icon is-small is-left"><i class="fa-solid fa-n" /></span>
<span class="icon"><i class="fa-solid fa-info" /></span> </div>
<span>The name to refers to this custom settings.</span> <span class="help">
</span> <span class="icon"><i class="fa-solid fa-info" /></span>
</div> <span>The name to refers to this custom settings.</span>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="format" v-text="'Format'" />
<div class="control has-icons-left">
<input type="text" class="input" id="format" v-model="form.format" :disabled="addInProgress">
<span class="icon is-small is-left"><i class="fa-solid fa-f" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>The yt-dlp <code>[--format, -f]</code> video format code. see <NuxtLink
href="https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#format-selection" target="blank">this
url</NuxtLink> for more info.</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="args" v-tooltip="'Extends current global yt-dlp config. (JSON)'">
JSON yt-dlp config
</label>
<div class="control">
<textarea class="textarea" id="args" v-model="form.args" :disabled="addInProgress" placeholder="{}" />
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Extends current global yt-dlp config with given options. Some fields are ignored like
<code>cookiefile</code>, <code>paths</code>, and <code>outtmpl</code> etc. Warning: Use with caution
some of those options can break yt-dlp or the frontend.</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="postprocessors" v-tooltip="'Things to do after download is done.'">
JSON yt-dlp Post-Processors
</label>
<div class="control">
<textarea class="textarea" id="postprocessors" v-model="form.postprocessors"
:disabled="addInProgress" />
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>
Post-processing operations, refer to <NuxtLink
href="https://github.com/yt-dlp/yt-dlp/tree/master/yt_dlp/postprocessor" target="blank">this url
</NuxtLink> for more info. It's easier for you to use the <b>Convert CLI options</b> to get what you
want and it will auto-populate the fields if necessary.
</span> </span>
</span> </div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="format" v-text="'Format'" />
<div class="control has-icons-left">
<input type="text" class="input" id="format" v-model="form.format" :disabled="addInProgress">
<span class="icon is-small is-left"><i class="fa-solid fa-f" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>The yt-dlp <code>[--format, -f]</code> video format code. see <NuxtLink
href="https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#format-selection" target="blank">this
url</NuxtLink> for more info.</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="folder">
Default Download path
</label>
<div class="control has-icons-left">
<input type="text" class="input" id="folder" placeholder="Leave empty to use default download path"
v-model="form.folder" :disabled="addInProgress" list="folders">
<span class="icon is-small is-left"><i class="fa-solid fa-folder" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Use this folder if non is given with URL. Leave empty to use default download path. Default
download path <code>{{ config.app.download_path }}</code>.</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="output_template">
Default Output template
</label>
<div class="control has-icons-left">
<input type="text" class="input" id="output_template" :disabled="addInProgress"
placeholder="Leave empty to use default template." v-model="form.template">
<span class="icon is-small is-left"><i class="fa-solid fa-file" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Use this output template if non are given with URL. if not set, it will defaults to
<code>{{ config.app.output_template }}</code>.
For more information <NuxtLink href="https://github.com/yt-dlp/yt-dlp#output-template"
target="_blank">visit this url</NuxtLink>.
</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="args" v-tooltip="'Extends current global yt-dlp config. (JSON)'">
JSON yt-dlp config
</label>
<div class="control">
<textarea class="textarea" id="args" v-model="form.args" :disabled="addInProgress"
placeholder="{}" />
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Extends current global yt-dlp config with given options. Some fields are ignored like
<code>cookiefile</code>, <code>paths</code>, and <code>outtmpl</code> etc. Warning: Use with
caution
some of those options can break yt-dlp or the frontend.</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="postprocessors"
v-tooltip="'Things to do after download is done.'">
JSON yt-dlp Post-Processors
</label>
<div class="control">
<textarea class="textarea" id="postprocessors" v-model="form.postprocessors"
:disabled="addInProgress" placeholder="[]" />
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>
Post-processing operations, refer to <NuxtLink
href="https://github.com/yt-dlp/yt-dlp/tree/master/yt_dlp/postprocessor" target="blank">this url
</NuxtLink> for more info. It's easier for you to use the <b>Convert CLI options</b> to get what
you
want and it will auto-populate the fields if necessary.
</span>
</span>
</div>
</div>
<div class="column is-12">
<div class="field">
<label class="label is-inline" for="cookies"
v-tooltip="'Netscape HTTP Cookie format.'">Cookies</label>
<div class="control">
<textarea class="textarea is-pre" id="cookies" v-model="form.cookies" :disabled="addInProgress"
placeholder="Leave empty to use default cookies" />
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Use this cookies if non are given with the URL. Use the <NuxtLink target="_blank"
to="https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp">
Recommended addon</NuxtLink> by yt-dlp to export cookies. The cookies MUST be in Netscape HTTP
Cookie format.
</span>
</span>
</div>
</div> </div>
</div> </div>
<div class="column is-12"> <div class="card-footer">
<div class="field is-grouped is-grouped-right"> <div class="card-footer-item">
<p class="control"> <button class="button is-fullwidth is-primary" :disabled="addInProgress" type="submit"
<button class="button is-primary" :disabled="addInProgress" type="submit" :class="{ 'is-loading': addInProgress }" form="presetForm">
:class="{ 'is-loading': addInProgress }" form="presetForm"> <span class="icon"><i class="fa-solid fa-save" /></span>
<span class="icon"><i class="fa-solid fa-save" /></span> <span>Save</span>
<span>Save</span> </button>
</button> </div>
</p> <div class="card-footer-item">
<p class="control"> <button class="button is-fullwidth is-danger" @click="emitter('cancel')" :disabled="addInProgress"
<button class="button is-danger" @click="emitter('cancel')" :disabled="addInProgress" type="button"> type="button">
<span class="icon"><i class="fa-solid fa-times" /></span> <span class="icon"><i class="fa-solid fa-times" /></span>
<span>Cancel</span> <span>Cancel</span>
</button> </button>
</p>
</div> </div>
</div> </div>
@ -176,6 +238,9 @@
</div> </div>
</form> </form>
</div> </div>
<datalist id="folders" v-if="config?.folders">
<option v-for="dir in config.folders" :key="dir" :value="dir" />
</datalist>
</main> </main>
</template> </template>
@ -204,6 +269,7 @@ const props = defineProps({
}, },
}) })
const config = useConfigStore()
const toast = useToast() const toast = useToast()
const convertInProgress = ref(false) const convertInProgress = ref(false)
const form = reactive(JSON.parse(JSON.stringify(props.preset))) const form = reactive(JSON.parse(JSON.stringify(props.preset)))
@ -252,7 +318,7 @@ const checkInfo = async () => {
} }
if (typeof copy.args === 'object') { if (typeof copy.args === 'object') {
copy.config = JSON.stringify(copy.args, null, 2); copy.args = JSON.stringify(copy.args, null, 2);
} }
if (typeof copy.postprocessors === 'object') { if (typeof copy.postprocessors === 'object') {
@ -285,7 +351,7 @@ const convertOptions = async () => {
return return
} }
if (form.format || form.args || form.postprocessors) { if (form.format || form.args || form.postprocessors || form.template || form.folder) {
if (false === confirm('This will overwrite the current form fields. Are you sure?')) { if (false === confirm('This will overwrite the current form fields. Are you sure?')) {
return return
} }
@ -305,6 +371,14 @@ const convertOptions = async () => {
delete response.opts.format delete response.opts.format
} }
if (response.output_template) {
form.template = response.output_template
}
if (response.download_path) {
form.folder = response.download_path
}
if (response.opts.postprocessors) { if (response.opts.postprocessors) {
form.postprocessors = JSON.stringify(response.opts.postprocessors, null, 2) form.postprocessors = JSON.stringify(response.opts.postprocessors, null, 2)
delete response.opts.postprocessors delete response.opts.postprocessors
@ -349,6 +423,14 @@ const importPreset = async () => {
form.postprocessors = JSON.stringify(preset.postprocessors, null, 2) form.postprocessors = JSON.stringify(preset.postprocessors, null, 2)
} }
if (preset.output_template) {
form.template = response.output_template
}
if (preset.folder) {
form.folder = response.folder
}
json_preset.value = '' json_preset.value = ''
} catch (e) { } catch (e) {

View file

@ -149,7 +149,7 @@
<div class="field"> <div class="field">
<label class="label is-inline" for="cookies" v-tooltip="'Netscape HTTP Cookie format.'">Cookies</label> <label class="label is-inline" for="cookies" v-tooltip="'Netscape HTTP Cookie format.'">Cookies</label>
<div class="control"> <div class="control">
<textarea class="textarea" id="cookies" v-model="form.cookies" :disabled="addInProgress"></textarea> <textarea class="textarea is-pre" id="cookies" v-model="form.cookies" :disabled="addInProgress" />
</div> </div>
<span class="help"> <span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>

View file

@ -252,7 +252,7 @@ onMounted(async () => socket.isConnected ? await reloadContent(true) : '')
const copyItem = item => { const copyItem = item => {
let data = JSON.parse(JSON.stringify(item)) let data = JSON.parse(JSON.stringify(item))
const keys = ['id', 'default', 'raw'] const keys = ['id', 'default', 'raw', 'cookies']
keys.forEach(key => { keys.forEach(key => {
if (key in data) { if (key in data) {
delete data[key] delete data[key]