diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml new file mode 100644 index 00000000..a3c11c0f --- /dev/null +++ b/.github/workflows/create-release.yml @@ -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 repository’s 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<> "$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 }} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1b594ce3..b953422d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -189,82 +189,3 @@ jobs: with: entrypoint: node 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<> "$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 }} diff --git a/app/library/Download.py b/app/library/Download.py index 6747ead6..d36cd49c 100644 --- a/app/library/Download.py +++ b/app/library/Download.py @@ -14,7 +14,7 @@ from .config import Config from .Emitter import Emitter from .ffprobe import ffprobe from .ItemDTO import ItemDTO -from .Utils import get_opts, merge_config +from .YTDLPOpts import YTDLPOpts LOG = logging.getLogger("download") @@ -114,22 +114,36 @@ class Download: def _download(self): try: - params: dict = get_opts(self.preset, merge_config(self.default_ytdl_opts, self.ytdl_opts)) - params.update( - { - "color": "no_color", - "paths": {"home": self.download_dir, "temp": self.temp_path}, - "outtmpl": {"default": self.template, "chapter": self.template_chapter}, - "noprogress": True, - "break_on_existing": True, - "progress_hooks": [self._progress_hook], - "postprocessor_hooks": [self._postprocessor_hook], - "ignoreerrors": False, - } + params = ( + YTDLPOpts.get_instance() + .preset(self.preset) + .add(self.ytdl_opts, from_user=True) + .add( + { + "color": "no_color", + "paths": { + "home": self.download_dir, + "temp": self.temp_path, + }, + "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["format"] = "best" + params.update( + { + "progress_hooks": [self._progress_hook], + "postprocessor_hooks": [self._postprocessor_hook], + } + ) if self.debug: params["verbose"] = True @@ -137,7 +151,9 @@ class Download: if self.info.cookies: 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) params["cookiefile"] = f.name except ValueError as e: @@ -163,6 +179,11 @@ class Download: 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) 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"}) except Exception as exc: + LOG.exception(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.') diff --git a/app/library/DownloadQueue.py b/app/library/DownloadQueue.py index fc718055..2da63433 100644 --- a/app/library/DownloadQueue.py +++ b/app/library/DownloadQueue.py @@ -19,8 +19,10 @@ from .Download import Download from .Emitter import Emitter from .EventsSubscriber import Events from .ItemDTO import ItemDTO +from .Presets import Presets 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") @@ -352,9 +354,21 @@ class DownloadQueue(metaclass=Singleton): template: str = "", already=None, ): + _preset = Presets.get_instance().get(name=preset) + config = config if config 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) yt_conf = {} 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), "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: diff --git a/app/library/HttpAPI.py b/app/library/HttpAPI.py index b07cc69d..fb77f979 100644 --- a/app/library/HttpAPI.py +++ b/app/library/HttpAPI.py @@ -708,7 +708,7 @@ class HttpAPI(Common): item["id"] = str(uuid.uuid4()) 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): item["args"] = json.loads(item.get("args")) diff --git a/app/library/Presets.py b/app/library/Presets.py index 5e45859a..e2f8a271 100644 --- a/app/library/Presets.py +++ b/app/library/Presets.py @@ -27,12 +27,21 @@ class Preset: format: str """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.""" - postprocessors: list | None = field(default_factory=list) + postprocessors: list = field(default_factory=list) """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 def serialize(self) -> dict: diff --git a/app/library/Utils.py b/app/library/Utils.py index 61b7eab9..dc73d4aa 100644 --- a/app/library/Utils.py +++ b/app/library/Utils.py @@ -15,7 +15,6 @@ from typing import Any import yt_dlp from Crypto.Cipher import AES -from yt_dlp.networking.impersonate import ImpersonateTarget from .LogWrapper import LogWrapper @@ -31,52 +30,7 @@ class StreamingError(Exception): """Raised when an error occurs during streaming.""" -def get_opts(preset: str, ytdl_opts: dict) -> dict: - """ - 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: +def get_video_info(url: str, ytdlp_opts: dict | None = None, no_archive: bool = True) -> Any | dict[str, Any] | None: """ 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. + 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: 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: + """ + 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() params: dict = { + **config, "color": "no_color", "extract_flat": True, "skip_download": True, "ignoreerrors": True, "ignore_no_formats_error": True, - **config, } # Remove keys that are not needed for info extraction. - keys: list = [ - "writeinfojson", - "writethumbnail", - "writedescription", - "writeautomaticsub", - "postprocessors", - ] - - for key in keys: - if key in params: - params.pop(key) + keys_to_remove = [key for key in params if str(key).startswith("write") or key in ["postprocessors"]] + for key in keys_to_remove: + params.pop(key, None) log_wrapper.add_target(target=logging.getLogger("yt-dlp"), level=logging.DEBUG if debug else logging.WARNING) if debug: @@ -166,7 +129,6 @@ def extract_info(config: dict, url: str, debug: bool = False) -> dict: params["quiet"] = True if "callback" in params: - # callback can be a function or dict with {level: level, func: target} if isinstance(params["callback"], dict): log_wrapper.add_target( 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: - """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) for key, value in source.items(): - destination_key_value = destination_copy.get(key) - if isinstance(value, dict) and isinstance(destination_key_value, dict): - destination_copy[key] = merge_dict(source=value, destination=destination_copy.setdefault(key, {})) - elif isinstance(value, list) and isinstance(destination_key_value, list): - destination_copy[key] = destination_key_value + value + if key in {"__class__", "__dict__", "__globals__", "__builtins__"}: + continue + + destination_value = destination_copy.get(key) + + # 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: - destination_copy[key] = value + destination_copy[key] = copy.deepcopy(value) 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: - config (dict): Default config - new_config (dict): User provided config + archive_file (str): Archive file path. + url (str): URL to check. 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 idDict = { @@ -301,11 +272,7 @@ def load_file(file: str, check_type=None) -> tuple[dict | list, bool, str]: if check_type: assert isinstance(opts, check_type) # noqa: S101 - return ( - opts, - True, - "", - ) + return (opts, True, "") except Exception: with open(file) as json_data: 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: assert isinstance(opts, check_type) # noqa: S101 - return ( - opts, - True, - "", - ) + return (opts, True, "") except AssertionError: - return ( - {}, - False, - f"Failed to assert that the contents '{type(opts)}' are of type '{check_type}'.", - ) + return ({}, False, f"Failed to assert that the contents '{type(opts)}' are of type '{check_type}'.") except Exception as e: - return ( - {}, - False, - f"{e}", - ) + return ({}, False, f"{e}") 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. if so check if any video file with the same id exists. - :param basePath: Base path to strip. - :param file: File to check. + Args: + 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[a-zA-Z0-9\-_]{11})(?=\])", file.stem, re.IGNORECASE) 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. - :param array_or_object: dict-like or object from which to retrieve values - :param path: string, list, or None. Represents the path to retrieve: - - If None or empty string, returns the entire structure. - - If list, tries each path and returns the first found. - - If string, navigates through nested dict keys separated by `separator`. - :param default: Value (or callable) returned if nothing is found. - :param separator: Separator for nested paths in strings. - :return: The found value or the default if not found. + Args: + array (dict|list): dict-like or object from which to retrieve values. + path (list|str|int): Represents the path to retrieve: + - If None or empty string, returns the entire structure. + - If list, tries each path and returns the first found. + - If string, navigates through nested dict keys separated by `separator`. + default (Any): Value (or callable) returned if nothing is 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 == "": return array @@ -523,8 +484,12 @@ def get_sidecar_subtitles(file: pathlib.Path) -> list[dict]: """ Get sidecar files for the given file. - :param file: File to get sidecar files for. - :return: List of sidecar files. + Args: + file (pathlib.Path): The video file. + + Returns: + list: List of sidecar files. + """ files = [] diff --git a/app/library/YTDLPOpts.py b/app/library/YTDLPOpts.py new file mode 100644 index 00000000..7e1c062d --- /dev/null +++ b/app/library/YTDLPOpts.py @@ -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 diff --git a/app/library/presets.json b/app/library/presets.json index dcc33cc3..52e0eae7 100644 --- a/app/library/presets.json +++ b/app/library/presets.json @@ -3,12 +3,18 @@ "id": "3e163c6c-64eb-4448-924f-814b629b3810", "name": "default", "format": "default", + "folder": "", + "template": "", + "cookies": "", "default": true }, { "id": "5bf9c42b-8852-468a-99f5-915622dfba25", "name": "Best video and audio", "format": "bv+ba/b", + "folder": "", + "template": "", + "cookies": "", "default": true }, { @@ -20,6 +26,9 @@ "vcodec:h264" ] }, + "folder": "", + "template": "", + "cookies": "", "default": true }, { @@ -31,6 +40,9 @@ "vcodec:h264" ] }, + "folder": "", + "template": "", + "cookies": "", "default": true }, { @@ -63,6 +75,9 @@ "when": "playlist" } ], + "folder": "", + "template": "", + "cookies": "", "default": true } ] diff --git a/ui/assets/css/style.css b/ui/assets/css/style.css index 83dfe4ea..f166f27f 100644 --- a/ui/assets/css/style.css +++ b/ui/assets/css/style.css @@ -221,7 +221,8 @@ hr { padding-top: 0.5em; } -.play-overlay, .is-pointer { +.play-overlay, +.is-pointer { cursor: pointer; } @@ -254,3 +255,7 @@ hr { object-fit: cover; object-position: top; } + +.is-pre { + white-space: pre; +} diff --git a/ui/components/NewDownload.vue b/ui/components/NewDownload.vue index 8cf879ee..18dcf896 100644 --- a/ui/components/NewDownload.vue +++ b/ui/components/NewDownload.vue @@ -107,8 +107,8 @@ Cookies
- + +