diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..1750f25a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+__pycache__
+node_modules
\ No newline at end of file
diff --git a/.ruff.toml b/.ruff.toml
new file mode 100644
index 00000000..0381d4da
--- /dev/null
+++ b/.ruff.toml
@@ -0,0 +1 @@
+line-length = 120
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index 3bfe9582..04f66a56 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,25 +1,70 @@
#
# youtube-dl Server Dockerfile
#
-# https://github.com/manbearwiz/youtube-dl-server-dockerfile
+# https://github.com/nbr23/youtube-dl-server
#
-FROM python:alpine
+ARG YOUTUBE_DL=yt-dlp
+FROM --platform=$BUILDPLATFORM node:22-alpine AS nodebuild
-RUN apk add --no-cache \
- ffmpeg \
- tzdata
+WORKDIR /app
+COPY ./front/package*.json /app
+RUN npm ci
+COPY ./front /app
+RUN npm run build
+
+FROM python:alpine AS wheels
+
+RUN apk add --no-cache g++
+COPY ./requirements.txt .
+RUN pip wheel --no-cache-dir --wheel-dir /out/wheels -r <(cat ./requirements.txt| grep -v youtube-dl | grep -v yt-dlp) \
+ && pip wheel --no-cache-dir --wheel-dir /out/wheels-youtube-dl -r <(cat ./requirements.txt| grep youtube-dl) \
+ && pip wheel --no-cache-dir --wheel-dir /out/wheels-yt-dlp -r <(cat ./requirements.txt| grep yt-dlp)
+
+FROM python:alpine AS base
+ARG ATOMICPARSLEY=0
+ARG YDLS_VERSION
+ARG YDLS_RELEASE_DATE
+
+ENV YDLS_VERSION=$YDLS_VERSION
+ENV YDLS_RELEASE_DATE=$YDLS_RELEASE_DATE
-RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app
+RUN apk add --no-cache ffmpeg tzdata mailcap
+RUN if [ $ATOMICPARSLEY == 1 ]; then apk add --no-cache -X http://dl-cdn.alpinelinux.org/alpine/edge/testing atomicparsley; ln /usr/bin/atomicparsley /usr/bin/AtomicParsley || true; fi
-COPY requirements.txt /usr/src/app/
-RUN pip install --no-cache-dir -r requirements.txt
+VOLUME "/youtube-dl"
+VOLUME "/app_config"
-COPY . /usr/src/app
+COPY --from=wheels /out/wheels /wheels
+RUN pip install --no-cache /wheels/*
-EXPOSE 8080
+COPY ./requirements.txt /usr/src/app/
+
+FROM base AS yt-dlp
+
+COPY --from=wheels /out/wheels-yt-dlp /wheels
+RUN pip install --no-cache /wheels/*
+RUN pip install --no-cache-dir -r <(cat /usr/src/app/requirements.txt| grep -v youtube-dl)
-VOLUME ["/youtube-dl"]
+FROM base AS youtube-dl
+COPY --from=wheels /out/wheels-youtube-dl /wheels/
+RUN pip install --no-cache /wheels/*
+RUN pip install --no-cache-dir -r <(cat /usr/src/app/requirements.txt| grep -v yt-dlp)
+
+FROM ${YOUTUBE_DL}
+
+COPY ./config.yml /usr/src/app/default_config.yml
+COPY ./ydl_server /usr/src/app/ydl_server
+COPY ./youtube-dl-server.py /usr/src/app/
+
+COPY --from=nodebuild /app/dist /usr/src/app/ydl_server/static
+
+EXPOSE 8080
+
+ENV YOUTUBE_DL=$YOUTUBE_DL
+ENV YDL_CONFIG_PATH='/app_config'
CMD [ "python", "-u", "./youtube-dl-server.py" ]
+
+HEALTHCHECK CMD wget 127.0.0.1:8080/api/info --spider -q -Y off
diff --git a/Jenkinsfile b/Jenkinsfile
new file mode 100644
index 00000000..acb91d77
--- /dev/null
+++ b/Jenkinsfile
@@ -0,0 +1,86 @@
+pipeline {
+ agent any
+
+ options {
+ disableConcurrentBuilds()
+ }
+
+ stages {
+ stage('Checkout'){
+ steps {
+ checkout scm
+ }
+ }
+ stage('Linting') {
+ steps {
+ script {
+ sh "ruff check .";
+ }
+ }
+ }
+ stage('Prep buildx') {
+ steps {
+ script {
+ env.BUILDX_BUILDER = getBuildxBuilder();
+ }
+ }
+ }
+ stage('Build yt_dlp Image') {
+ steps {
+ withCredentials([usernamePassword(credentialsId: 'dockerhub', usernameVariable: 'DOCKERHUB_CREDENTIALS_USR', passwordVariable: 'DOCKERHUB_CREDENTIALS_PSW')]) {
+ sh 'docker login -u $DOCKERHUB_CREDENTIALS_USR -p "$DOCKERHUB_CREDENTIALS_PSW"'
+ }
+ sh """
+ docker buildx build \
+ --pull \
+ --builder \$BUILDX_BUILDER \
+ --platform linux/amd64,linux/arm64,linux/arm/v7 \
+ --build-arg YDLS_VERSION=`git rev-parse --short HEAD` \
+ --build-arg YDLS_RELEASE_DATE="`git log -1 --pretty='format:%cd' --date=format:'%Y-%m-%d %H:%M:%S'`" \
+ --build-arg YOUTUBE_DL=yt-dlp \
+ --build-arg ATOMICPARSLEY=1 \
+ -t nbr23/youtube-dl-server:latest \
+ -t nbr23/youtube-dl-server:yt-dlp \
+ -t nbr23/youtube-dl-server:`git rev-parse --short HEAD`-yt-dlp \
+ -t nbr23/youtube-dl-server:${GIT_COMMIT}-`date +%s`-yt-dlp \
+ -t nbr23/youtube-dl-server:yt-dlp_atomicparsley \
+ ${ "$GIT_BRANCH" == "master" ? "--push" : ""} .
+ """
+ }
+ }
+ stage('Build Youtube-dl Image') {
+ steps {
+ withCredentials([usernamePassword(credentialsId: 'dockerhub', usernameVariable: 'DOCKERHUB_CREDENTIALS_USR', passwordVariable: 'DOCKERHUB_CREDENTIALS_PSW')]) {
+ sh 'docker login -u $DOCKERHUB_CREDENTIALS_USR -p "$DOCKERHUB_CREDENTIALS_PSW"'
+ }
+ sh """
+ docker buildx build \
+ --pull \
+ --builder \$BUILDX_BUILDER \
+ --platform linux/amd64,linux/arm64,linux/arm/v7 \
+ --build-arg YDLS_VERSION=`git rev-parse --short HEAD` \
+ --build-arg YDLS_RELEASE_DATE="`git log -1 --pretty='format:%cd' --date=format:'%Y-%m-%d %H:%M:%S'`" \
+ --build-arg ATOMICPARSLEY=1 \
+ --build-arg YOUTUBE_DL=youtube-dl \
+ -t nbr23/youtube-dl-server:youtube-dl \
+ -t nbr23/youtube-dl-server:`git rev-parse --short HEAD`-youtube-dl \
+ -t nbr23/youtube-dl-server:${GIT_COMMIT}-`date +%s`-youtube-dl \
+ -t nbr23/youtube-dl-server:youtube-dl_atomicparsley \
+ ${ "$GIT_BRANCH" == "master" ? "--push" : ""} .
+ """
+ }
+ }
+ stage('Sync github repo') {
+ when { branch 'master' }
+ steps {
+ syncRemoteBranch('git@github.com:nbr23/youtube-dl-server.git', 'master')
+ }
+ }
+ }
+ post {
+ always {
+ sh 'docker buildx stop $BUILDX_BUILDER || true'
+ sh 'docker buildx rm $BUILDX_BUILDER || true'
+ }
+ }
+}
diff --git a/README.md b/README.md
index c8e6ee5c..4c8cd94b 100644
--- a/README.md
+++ b/README.md
@@ -1,64 +1,186 @@
-![Docker Stars Shield](https://img.shields.io/docker/stars/kmb32123/youtube-dl-server.svg?style=flat-square)
-![Docker Pulls Shield](https://img.shields.io/docker/pulls/kmb32123/youtube-dl-server.svg?style=flat-square)
-[![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](https://raw.githubusercontent.com/manbearwiz/youtube-dl-server/master/LICENSE)
+![Docker Stars Shield](https://img.shields.io/docker/stars/nbr23/youtube-dl-server.svg?style=flat-square)
+![Docker Pulls Shield](https://img.shields.io/docker/pulls/nbr23/youtube-dl-server.svg?style=flat-square)
+[![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](https://raw.githubusercontent.com/nbr23/youtube-dl-server/master/LICENSE)
# youtube-dl-server
-Very spartan Web and REST interface for downloading youtube videos onto a server. [`bottle`](https://github.com/bottlepy/bottle) + [`youtube-dl`](https://github.com/rg3/youtube-dl).
+Simple Web and REST interface for downloading youtube videos onto a server.
+[`starlette`](https://www.starlette.io/) +
+[yt-dlp](https://github.com/yt-dlp/yt-dlp) / [`youtube-dl`](https://github.com/rg3/youtube-dl)
+
+Forked from [manbearwiz/youtube-dl-server](https://github.com/manbearwiz/youtube-dl-server).
![screenshot][1]
+
+![screenshot][2]
+
## Running
+For easier deployment, a docker image is available on
+[dockerhub](https://hub.docker.com/r/nbr23/youtube-dl-server):
+
+- `nbr23/youtube-dl-server:yt-dlp` or simply `nbr23/youtube-dl-server` to use `yt-dlp`
+- `nbr23/youtube-dl-server:youtube-dl` to use `youtube-dl`. Note that the latest release of `youtube-dl` is pretty [outdated](https://github.com/ytdl-org/youtube-dl/releases/tag/2021.12.17).
+
### Docker CLI
-This example uses the docker run command to create the container to run the app. Here we also use host networking for simplicity. Also note the `-v` argument. This directory will be used to output the resulting videos
+This example uses the docker run command to create the container to run the
+app. Note the `-v` argument to specify the volume and its binding on the host.
+This directory will be used to output the resulting videos.
```shell
-docker run -d --net="host" --name youtube-dl -v /home/core/youtube-dl:/youtube-dl kmb32123/youtube-dl-server
+docker run -d --name youtube-dl -p 8080:8080 -v $HOME/youtube-dl:/youtube-dl nbr23/youtube-dl-server:latest
+```
+
+OR for yt-dlp:
+
+```shell
+docker run -d --name youtube-dl -p 8080:8080 -v $HOME/youtube-dl:/youtube-dl nbr23/youtube-dl-server:yt-dlp
```
### Docker Compose
-This is an example service definition that could be put in `docker-compose.yml`. This service uses a VPN client container for its networking.
+This is an example service definition that could be put in `docker-compose.yml`.
```yml
youtube-dl:
- image: "kmb32123/youtube-dl-server"
- network_mode: "service:vpn"
+ image: "nbr23/youtube-dl-server:latest"
volumes:
- - /home/core/youtube-dl:/youtube-dl
+ - $HOME/youtube-dl:/youtube-dl
+ - ./config.yml:/app_config/config.yml:ro # Overwrite the container's config file with your own configuration
restart: always
```
-### Python
+## Configuration
+
+Configuration is done through the config.yml file at the root of the project.
+
+An alternate configuration path or file path can be forced by setting the environment
+variable `YDL_CONFIG_PATH`:
+
+```shell
+export YDL_CONFIG_PATH=/var/local/youtube-dl-server/config.yml
+```
+
+In the above case, if `/var/local/youtube-dl-server/config.yml` does not exist, it will be created with the default options.
+
+```shell
+export YDL_CONFIG_PATH=/var/local/youtube-dl-server/
+```
+
+In the above case, if `/var/local/youtube-dl-server/config.yml` does not exist, it will be created with the default options as well.
+
+The configuration file must contain at least the following variables:
+
+```yaml
+ydl_server:
+ port: 8080
+ host: 0.0.0.0
+ metadata_db_path: '/youtube-dl/.ydl-metadata.db'
+
+ydl_options:
+ output: '/youtube-dl/%(title)s [%(id)s].%(ext)s'
+ cache-dir: '/youtube-dl/.cache'
+```
+
+### Extra options
+
+Additional youtube-dl parameters can be set in the `ydl_options` sections. To
+do this, simply add regular youtube-dl parameters, removing the leading `--`.
+
+For example, to write subtitles in spanish, the youtube-dl command would be:
+
+`youtube-dl --write-sub --sub-lang es URL`
+
+Which would translate to the following `ydl_options` in `config.yml`:
+
+```yaml
+ydl_options:
+ output: '/tmp/youtube-dl/%(title)s [%(id)s].%(ext)s'
+ cache-dir: '/tmp/youtube-dl/.cache'
+ write-sub: True
+ sub-lang: es
+```
+
+### Profiles
+
+You can also define profiles. They allow you to define configuration sets that can be selected in the UI.
+
+Sample:
+
+```yaml
+profiles:
+ podcast:
+ name: 'Audio Podcasts'
+ ydl_options:
+ output: '/youtube-dl/Podcast/%(title)s [%(id)s].%(ext)s'
+ format: bestaudio/best
+ write-thumbnail: True
+ embed-thumbnail: True
+ add-metadata: True
+ audio-quality: 0
+ extract-audio: True
+ audio-format: mp3
+ philosophy_lectures:
+ name: 'Philosophy Lectures'
+ ydl_options:
+ output: '/youtube-dl/Lectures/Philosophy/%(title)s [%(id)s].%(ext)s'
+ write-thumbnail: True
+ embed-thumbnail: True
+ add-metadata: True
+ verbose: True
+```
+
+![screenshot][3]
+
+## Python
+
+If you have python ^3.3.0 installed in your PATH you can simply run like this,
+providing optional environment variable overrides inline.
+
+Install the python dependencies from `requirements.txt`:
+
+```shell
+pip install -r requirements.txt
+```
+
+You can run
+[bootstrap.sh](https://github.com/nbr23/youtube-dl-server/blob/master/bootstrap.sh)
+to download the required front-end libraries (jquery, bootstrap).
+
+```shell
+python3 -u ./youtube-dl-server.py
+```
-If you have python ^3.3.0 installed in your PATH you can simply run like this, providing optional environment variable overrides inline.
+To force a specific `youtube-dl` version/fork (eg `yt-dlp`), use the
+variable `YOUTUBE_DL`:
```shell
-sudo YDL_SERVER_PORT=8123 python3 -u ./youtube-dl-server.py
+YOUTUBE_DL=yt-dlp python3 -u ./youtube-dl-server.py
```
## Usage
### Start a download remotely
-Downloads can be triggered by supplying the `{{url}}` of the requested video through the Web UI or through the REST interface via curl, etc.
+Downloads can be triggered by supplying the `{{url}}` of the requested video
+through the Web UI or through the REST interface via curl, etc.
-#### HTML
+### HTML
-Just navigate to `http://{{host}}:8080/youtube-dl` and enter the requested `{{url}}`.
+Just navigate to `http://{{host}}:8080/` and enter the requested `{{url}}`.
-#### Curl
+### Curl
```shell
-curl -X POST --data-urlencode "url={{url}}" http://{{host}}:8080/youtube-dl/q
+curl -X POST -H 'Content-Type: application/json' --data-raw '{"url":"{{ URL }}","format":"video/best"}' http://{{host}}:8080/api/downloads
```
-#### Fetch
+### Fetch
```javascript
-fetch(`http://${host}:8080/youtube-dl/q`, {
+fetch(`http://${host}:8080/api/downloads`, {
method: "POST",
body: new URLSearchParams({
url: url,
@@ -67,18 +189,46 @@ fetch(`http://${host}:8080/youtube-dl/q`, {
});
```
-#### Bookmarklet
+### Bookmarklet
-Add the following bookmarklet to your bookmark bar so you can conviently send the current page url to your youtube-dl-server instance.
+Add the following bookmarklet to your bookmark bar so you can conviently send
+the current page url to your youtube-dl-server instance.
+
+#### HTTPS
+
+If your youtube-dl-server is served through https (behind a reverse proxy
+handling https for example), you can use the following bookmarklet:
```javascript
-javascript:!function(){fetch("http://${host}:8080/youtube-dl/q",{body:new URLSearchParams({url:window.location.href,format:"bestvideo"}),method:"POST"})}();
+javascript:fetch("https://${host}/api/downloads",{body:JSON.stringify({url:window.location.href,format:"bestvideo"}),method:"POST",headers:{'Content-Type':'application/json'}});
```
-## Implementation
+#### Plain text
+
+If you are hosting it without HTTPS, the previous bookmarklet will likely be
+blocked by your browser (as it will generate mixed content when used on HTTPS
+sites).
+
+Instead, you can use the following bookmarklet:
+
+```javascript
+javascript:(function(){document.body.innerHTML += '
';document.ydl_form.submit()})();
+```
+
+## Notes
+
+### Support extra formats
-The server uses [`bottle`](https://github.com/bottlepy/bottle) for the web framework and [`youtube-dl`](https://github.com/rg3/youtube-dl) to handle the downloading. The integration with youtube-dl makes use of their [python api](https://github.com/rg3/youtube-dl#embedding-youtube-dl).
+`ffmpeg` is required for format conversion and audio extraction in some
+scenarios.
+## Additional references
-This docker image is based on [`python:alpine`](https://registry.hub.docker.com/_/python/) and consequently [`alpine:3.8`](https://hub.docker.com/_/alpine/).
+* [ansible-role-youtubedl-server](https://github.com/nbr23/ansible-role-youtubedl-server)
+* [ytdl-k8s](https://github.com/droopy4096/ytdl-k8s) - `youtube-dl-server` Helm chart (uses `youtube-dl-server` image for kubernetes deployment)
+* [starlette](https://www.starlette.io/)
+* [youtube-dl](https://github.com/rg3/youtube-dl)
+* [yt-dlp](https://github.com/yt-dlp/yt-dlp)
[1]:youtube-dl-server.png
+[2]:youtube-dl-server-logs.png
+[3]:youtube-dl-server-profiles.png
diff --git a/config.yml b/config.yml
new file mode 100644
index 00000000..3fc68dbe
--- /dev/null
+++ b/config.yml
@@ -0,0 +1,30 @@
+ydl_server: # youtube-dl-server specific settings
+ port: 8080 # Port youtube-dl-server should listen on
+ host: 0.0.0.0 # IP youtube-dl-server should bind to
+ debug: False # Enable/Disable debug mode
+ metadata_db_path: '/youtube-dl/.ydl-metadata.db' # Path to metadata DB
+ output_playlist: '/youtube-dl/%(playlist_title)s [%(playlist_id)s]/%(title)s.%(ext)s' # Playlist output directory template
+ max_log_entries: 100 # Maximum number of job log history to keep
+ forwarded_allow_ips: None # uvicorn Comma seperated list of IPs to trust with proxy headers.
+ proxy_headers: True # uvicorn flag Enable/Disable X-Forwarded-Proto, X-Forwarded-For, X-Forwarded-Port to populate remote address info.
+ default_format: video/best # Default format selection
+ download_workers_count: 2 # Number of download worker threads
+
+ydl_options: # youtube-dl options
+ output: '/youtube-dl/%(title)s [%(id)s].%(ext)s' # output directory template
+ cache-dir: '/youtube-dl/.cache' # youtube-dl cache directory
+ ignore-errors: True # instruct youtube-dl to skip errors
+ age-limit: 6 # minimal age requirement / parental control setting
+
+profiles:
+ podcast:
+ name: 'Audio Podcasts'
+ ydl_options:
+ output: '/youtube-dl/Podcast/%(title)s [%(id)s].%(ext)s'
+ format: bestaudio/best
+ write-thumbnail: True
+ embed-thumbnail: True
+ add-metadata: True
+ audio-quality: 0
+ extract-audio: True
+ audio-format: mp3
diff --git a/front/favicon.svg b/front/favicon.svg
new file mode 100644
index 00000000..20046e0c
--- /dev/null
+++ b/front/favicon.svg
@@ -0,0 +1,213 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/front/index.html b/front/index.html
new file mode 100644
index 00000000..f64a7e9f
--- /dev/null
+++ b/front/index.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+ youtube-dl-server
+
+
+
+
+
+
diff --git a/front/package-lock.json b/front/package-lock.json
new file mode 100644
index 00000000..6b0eeadf
--- /dev/null
+++ b/front/package-lock.json
@@ -0,0 +1,2367 @@
+{
+ "name": "youtube-dl-server",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "youtube-dl-server",
+ "version": "0.0.0",
+ "dependencies": {
+ "bootstrap": "^5.3.3",
+ "lodash": "^4.17.21",
+ "vue": "^3.4.27",
+ "vue-cookies": "^1.8.4",
+ "vue-router": "^4.3.2"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^5.0.4",
+ "eslint": "^9.3.0",
+ "eslint-config-google": "^0.14.0",
+ "eslint-plugin-vue": "^9.26.0",
+ "vite": "^5.2.11"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.24.8",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz",
+ "integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.24.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz",
+ "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.25.6",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.6.tgz",
+ "integrity": "sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.25.6"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.25.6",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.6.tgz",
+ "integrity": "sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.24.8",
+ "@babel/helper-validator-identifier": "^7.24.7",
+ "to-fast-properties": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
+ "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==",
+ "dev": true,
+ "dependencies": {
+ "eslint-visitor-keys": "^3.3.0"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.11.0",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz",
+ "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/config-array": {
+ "version": "0.18.0",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.18.0.tgz",
+ "integrity": "sha512-fTxvnS1sRMu3+JjXwJG0j/i4RT9u4qJ+lqS/yCGap4lH4zZGzQ7tu+xZqQmcMZq5OBZDL4QRxQzRjkWcGt8IVw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/object-schema": "^2.1.4",
+ "debug": "^4.3.1",
+ "minimatch": "^3.1.2"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/eslintrc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz",
+ "integrity": "sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==",
+ "dev": true,
+ "dependencies": {
+ "ajv": "^6.12.4",
+ "debug": "^4.3.2",
+ "espree": "^10.0.1",
+ "globals": "^14.0.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.1.0",
+ "minimatch": "^3.1.2",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "9.9.1",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.9.1.tgz",
+ "integrity": "sha512-xIDQRsfg5hNBqHz04H1R3scSVwmI+KUbqjsQKHKQ1DAUSaUjYPReZZmS/5PNiKu1fUvzDd6H7DEDKACSEhu+TQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/object-schema": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz",
+ "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.0.tgz",
+ "integrity": "sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==",
+ "dev": true,
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
+ "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
+ "license": "MIT"
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@popperjs/core": {
+ "version": "2.11.8",
+ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
+ "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
+ "peer": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/popperjs"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.24.0.tgz",
+ "integrity": "sha512-Q6HJd7Y6xdB48x8ZNVDOqsbh2uByBhgK8PiQgPhwkIw/HC/YX5Ghq2mQY5sRMZWHb3VsFkWooUVOZHKr7DmDIA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.24.0.tgz",
+ "integrity": "sha512-ijLnS1qFId8xhKjT81uBHuuJp2lU4x2yxa4ctFPtG+MqEE6+C5f/+X/bStmxapgmwLwiL3ih122xv8kVARNAZA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.24.0.tgz",
+ "integrity": "sha512-bIv+X9xeSs1XCk6DVvkO+S/z8/2AMt/2lMqdQbMrmVpgFvXlmde9mLcbQpztXm1tajC3raFDqegsH18HQPMYtA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.24.0.tgz",
+ "integrity": "sha512-X6/nOwoFN7RT2svEQWUsW/5C/fYMBe4fnLK9DQk4SX4mgVBiTA9h64kjUYPvGQ0F/9xwJ5U5UfTbl6BEjaQdBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.24.0.tgz",
+ "integrity": "sha512-0KXvIJQMOImLCVCz9uvvdPgfyWo93aHHp8ui3FrtOP57svqrF/roSSR5pjqL2hcMp0ljeGlU4q9o/rQaAQ3AYA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.24.0.tgz",
+ "integrity": "sha512-it2BW6kKFVh8xk/BnHfakEeoLPv8STIISekpoF+nBgWM4d55CZKc7T4Dx1pEbTnYm/xEKMgy1MNtYuoA8RFIWw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.24.0.tgz",
+ "integrity": "sha512-i0xTLXjqap2eRfulFVlSnM5dEbTVque/3Pi4g2y7cxrs7+a9De42z4XxKLYJ7+OhE3IgxvfQM7vQc43bwTgPwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.24.0.tgz",
+ "integrity": "sha512-9E6MKUJhDuDh604Qco5yP/3qn3y7SLXYuiC0Rpr89aMScS2UAmK1wHP2b7KAa1nSjWJc/f/Lc0Wl1L47qjiyQw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.24.0.tgz",
+ "integrity": "sha512-2XFFPJ2XMEiF5Zi2EBf4h73oR1V/lycirxZxHZNc93SqDN/IWhYYSYj8I9381ikUFXZrz2v7r2tOVk2NBwxrWw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.24.0.tgz",
+ "integrity": "sha512-M3Dg4hlwuntUCdzU7KjYqbbd+BLq3JMAOhCKdBE3TcMGMZbKkDdJ5ivNdehOssMCIokNHFOsv7DO4rlEOfyKpg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.24.0.tgz",
+ "integrity": "sha512-mjBaoo4ocxJppTorZVKWFpy1bfFj9FeCMJqzlMQGjpNPY9JwQi7OuS1axzNIk0nMX6jSgy6ZURDZ2w0QW6D56g==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.24.0.tgz",
+ "integrity": "sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.24.0.tgz",
+ "integrity": "sha512-w1i+L7kAXZNdYl+vFvzSZy8Y1arS7vMgIy8wusXJzRrPyof5LAb02KGr1PD2EkRcl73kHulIID0M501lN+vobQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.24.0.tgz",
+ "integrity": "sha512-VXBrnPWgBpVDCVY6XF3LEW0pOU51KbaHhccHw6AS6vBWIC60eqsH19DAeeObl+g8nKAz04QFdl/Cefta0xQtUQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.24.0.tgz",
+ "integrity": "sha512-xrNcGDU0OxVcPTH/8n/ShH4UevZxKIO6HJFK0e15XItZP2UcaiLFd5kiX7hJnqCbSztUF8Qot+JWBC/QXRPYWQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.24.0.tgz",
+ "integrity": "sha512-fbMkAF7fufku0N2dE5TBXcNlg0pt0cJue4xBRE2Qc5Vqikxr4VCgKj/ht6SMdFcOacVA9rqF70APJ8RN/4vMJw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
+ "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
+ "dev": true
+ },
+ "node_modules/@vitejs/plugin-vue": {
+ "version": "5.1.4",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.1.4.tgz",
+ "integrity": "sha512-N2XSI2n3sQqp5w7Y/AN/L2XDjBIRGqXko+eDp42sydYSBeJuSm5a1sLf8zakmo8u7tA8NmBgoDLA1HeOESjp9A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^5.0.0",
+ "vue": "^3.2.25"
+ }
+ },
+ "node_modules/@vue/compiler-core": {
+ "version": "3.5.12",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.12.tgz",
+ "integrity": "sha512-ISyBTRMmMYagUxhcpyEH0hpXRd/KqDU4ymofPgl2XAkY9ZhQ+h0ovEZJIiPop13UmR/54oA2cgMDjgroRelaEw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.25.3",
+ "@vue/shared": "3.5.12",
+ "entities": "^4.5.0",
+ "estree-walker": "^2.0.2",
+ "source-map-js": "^1.2.0"
+ }
+ },
+ "node_modules/@vue/compiler-dom": {
+ "version": "3.5.12",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.12.tgz",
+ "integrity": "sha512-9G6PbJ03uwxLHKQ3P42cMTi85lDRvGLB2rSGOiQqtXELat6uI4n8cNz9yjfVHRPIu+MsK6TE418Giruvgptckg==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-core": "3.5.12",
+ "@vue/shared": "3.5.12"
+ }
+ },
+ "node_modules/@vue/compiler-sfc": {
+ "version": "3.5.12",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.12.tgz",
+ "integrity": "sha512-2k973OGo2JuAa5+ZlekuQJtitI5CgLMOwgl94BzMCsKZCX/xiqzJYzapl4opFogKHqwJk34vfsaKpfEhd1k5nw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.25.3",
+ "@vue/compiler-core": "3.5.12",
+ "@vue/compiler-dom": "3.5.12",
+ "@vue/compiler-ssr": "3.5.12",
+ "@vue/shared": "3.5.12",
+ "estree-walker": "^2.0.2",
+ "magic-string": "^0.30.11",
+ "postcss": "^8.4.47",
+ "source-map-js": "^1.2.0"
+ }
+ },
+ "node_modules/@vue/compiler-ssr": {
+ "version": "3.5.12",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.12.tgz",
+ "integrity": "sha512-eLwc7v6bfGBSM7wZOGPmRavSWzNFF6+PdRhE+VFJhNCgHiF8AM7ccoqcv5kBXA2eWUfigD7byekvf/JsOfKvPA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.12",
+ "@vue/shared": "3.5.12"
+ }
+ },
+ "node_modules/@vue/devtools-api": {
+ "version": "6.6.4",
+ "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
+ "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
+ "license": "MIT"
+ },
+ "node_modules/@vue/reactivity": {
+ "version": "3.5.12",
+ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.12.tgz",
+ "integrity": "sha512-UzaN3Da7xnJXdz4Okb/BGbAaomRHc3RdoWqTzlvd9+WBR5m3J39J1fGcHes7U3za0ruYn/iYy/a1euhMEHvTAg==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/shared": "3.5.12"
+ }
+ },
+ "node_modules/@vue/runtime-core": {
+ "version": "3.5.12",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.12.tgz",
+ "integrity": "sha512-hrMUYV6tpocr3TL3Ad8DqxOdpDe4zuQY4HPY3X/VRh+L2myQO8MFXPAMarIOSGNu0bFAjh1yBkMPXZBqCk62Uw==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.12",
+ "@vue/shared": "3.5.12"
+ }
+ },
+ "node_modules/@vue/runtime-dom": {
+ "version": "3.5.12",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.12.tgz",
+ "integrity": "sha512-q8VFxR9A2MRfBr6/55Q3umyoN7ya836FzRXajPB6/Vvuv0zOPL+qltd9rIMzG/DbRLAIlREmnLsplEF/kotXKA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.12",
+ "@vue/runtime-core": "3.5.12",
+ "@vue/shared": "3.5.12",
+ "csstype": "^3.1.3"
+ }
+ },
+ "node_modules/@vue/server-renderer": {
+ "version": "3.5.12",
+ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.12.tgz",
+ "integrity": "sha512-I3QoeDDeEPZm8yR28JtY+rk880Oqmj43hreIBVTicisFTx/Dl7JpG72g/X7YF8hnQD3IFhkky5i2bPonwrTVPg==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-ssr": "3.5.12",
+ "@vue/shared": "3.5.12"
+ },
+ "peerDependencies": {
+ "vue": "3.5.12"
+ }
+ },
+ "node_modules/@vue/shared": {
+ "version": "3.5.12",
+ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.12.tgz",
+ "integrity": "sha512-L2RPSAwUFbgZH20etwrXyVyCBu9OxRSi8T/38QsvnkJyvq2LufW2lDCOzm7t/U9C1mkhJGWYfCuFBCmIuNivrg==",
+ "license": "MIT"
+ },
+ "node_modules/acorn": {
+ "version": "8.12.1",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
+ "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.12.6",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "dev": true,
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true
+ },
+ "node_modules/boolbase": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
+ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
+ "dev": true
+ },
+ "node_modules/bootstrap": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz",
+ "integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/twbs"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/bootstrap"
+ }
+ ],
+ "peerDependencies": {
+ "@popperjs/core": "^2.11.8"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+ "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "dev": true,
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
+ "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
+ "dev": true,
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "dev": true,
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
+ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
+ },
+ "node_modules/debug": {
+ "version": "4.3.4",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+ "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+ "dev": true,
+ "dependencies": {
+ "ms": "2.1.2"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true
+ },
+ "node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "9.9.1",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.9.1.tgz",
+ "integrity": "sha512-dHvhrbfr4xFQ9/dq+jcVneZMyRYLjggWjk6RVsIiHsP8Rz6yZ8LvZ//iU4TrZF+SXWG+JkNF2OyiZRvzgRDqMg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.2.0",
+ "@eslint-community/regexpp": "^4.11.0",
+ "@eslint/config-array": "^0.18.0",
+ "@eslint/eslintrc": "^3.1.0",
+ "@eslint/js": "9.9.1",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@humanwhocodes/retry": "^0.3.0",
+ "@nodelib/fs.walk": "^1.2.8",
+ "ajv": "^6.12.4",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.2",
+ "debug": "^4.3.2",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^8.0.2",
+ "eslint-visitor-keys": "^4.0.0",
+ "espree": "^10.1.0",
+ "esquery": "^1.5.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^8.0.0",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "is-path-inside": "^3.0.3",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "levn": "^0.4.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.2",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3",
+ "strip-ansi": "^6.0.1",
+ "text-table": "^0.2.0"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-config-google": {
+ "version": "0.14.0",
+ "resolved": "https://registry.npmjs.org/eslint-config-google/-/eslint-config-google-0.14.0.tgz",
+ "integrity": "sha512-WsbX4WbjuMvTdeVL6+J3rK1RGhCTqjsFjX7UMSMgZiyxxaNLkoJENbrGExzERFeoTpGw3F3FypTiWAP9ZXzkEw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "peerDependencies": {
+ "eslint": ">=5.16.0"
+ }
+ },
+ "node_modules/eslint-plugin-vue": {
+ "version": "9.27.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-9.27.0.tgz",
+ "integrity": "sha512-5Dw3yxEyuBSXTzT5/Ge1X5kIkRTQ3nvBn/VwPwInNiZBSJOO/timWMUaflONnFBzU6NhB68lxnCda7ULV5N7LA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.4.0",
+ "globals": "^13.24.0",
+ "natural-compare": "^1.4.0",
+ "nth-check": "^2.1.1",
+ "postcss-selector-parser": "^6.0.15",
+ "semver": "^7.6.0",
+ "vue-eslint-parser": "^9.4.3",
+ "xml-name-validator": "^4.0.0"
+ },
+ "engines": {
+ "node": "^14.17.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "eslint": "^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0"
+ }
+ },
+ "node_modules/eslint-plugin-vue/node_modules/globals": {
+ "version": "13.24.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
+ "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "type-fest": "^0.20.2"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.0.2.tgz",
+ "integrity": "sha512-6E4xmrTw5wtxnLA5wYL3WDfhZ/1bUBGOXV0zQvVRDOtrR8D0p6W7fs3JweNYhwRYeGvd/1CKX2se0/2s7Q/nJA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz",
+ "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==",
+ "dev": true,
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/espree": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-10.1.0.tgz",
+ "integrity": "sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.12.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^4.0.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz",
+ "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==",
+ "dev": true,
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true
+ },
+ "node_modules/fastq": {
+ "version": "1.17.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",
+ "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==",
+ "dev": true,
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/file-entry-cache": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+ "dev": true,
+ "dependencies": {
+ "flat-cache": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+ "dev": true,
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz",
+ "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==",
+ "dev": true
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/globals": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
+ "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz",
+ "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
+ "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+ "dev": true,
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-path-inside": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
+ "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true
+ },
+ "node_modules/js-yaml": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+ "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+ "dev": true,
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+ "dev": true
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.11",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz",
+ "integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+ "dev": true
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.7",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
+ "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true
+ },
+ "node_modules/nth-check": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
+ "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
+ "dev": true,
+ "dependencies": {
+ "boolbase": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/nth-check?sponsor=1"
+ }
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "dev": true,
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz",
+ "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==",
+ "license": "ISC"
+ },
+ "node_modules/postcss": {
+ "version": "8.4.47",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz",
+ "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.7",
+ "picocolors": "^1.1.0",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-selector-parser": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.0.tgz",
+ "integrity": "sha512-UMz42UD0UY0EApS0ZL9o1XnLhSTtvvvLe5Dc2H2O56fvRZi+KulDyf5ctDhhtYJBGKStV2FL1fy6253cmLgqVQ==",
+ "dev": true,
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
+ "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
+ "dev": true,
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.24.0.tgz",
+ "integrity": "sha512-DOmrlGSXNk1DM0ljiQA+i+o0rSLhtii1je5wgk60j49d1jHT5YYttBv1iWOnYSTG+fZZESUOSNiAl89SIet+Cg==",
+ "dev": true,
+ "dependencies": {
+ "@types/estree": "1.0.6"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.24.0",
+ "@rollup/rollup-android-arm64": "4.24.0",
+ "@rollup/rollup-darwin-arm64": "4.24.0",
+ "@rollup/rollup-darwin-x64": "4.24.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.24.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.24.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.24.0",
+ "@rollup/rollup-linux-arm64-musl": "4.24.0",
+ "@rollup/rollup-linux-powerpc64le-gnu": "4.24.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.24.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.24.0",
+ "@rollup/rollup-linux-x64-gnu": "4.24.0",
+ "@rollup/rollup-linux-x64-musl": "4.24.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.24.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.24.0",
+ "@rollup/rollup-win32-x64-msvc": "4.24.0",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.6.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz",
+ "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==",
+ "dev": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/text-table": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
+ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
+ "dev": true
+ },
+ "node_modules/to-fast-properties": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
+ "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/type-fest": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+ "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "dev": true
+ },
+ "node_modules/vite": {
+ "version": "5.4.10",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.10.tgz",
+ "integrity": "sha512-1hvaPshuPUtxeQ0hsVH3Mud0ZanOLwVTneA1EgbAM5LhaZEqyPWGRQ7BtaMvUrTDeEaC8pxtj6a6jku3x4z6SQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue": {
+ "version": "3.5.12",
+ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.12.tgz",
+ "integrity": "sha512-CLVZtXtn2ItBIi/zHZ0Sg1Xkb7+PU32bJJ8Bmy7ts3jxXTcbfsEfBivFYYWz1Hur+lalqGAh65Coin0r+HRUfg==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.12",
+ "@vue/compiler-sfc": "3.5.12",
+ "@vue/runtime-dom": "3.5.12",
+ "@vue/server-renderer": "3.5.12",
+ "@vue/shared": "3.5.12"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue-cookies": {
+ "version": "1.8.4",
+ "resolved": "https://registry.npmjs.org/vue-cookies/-/vue-cookies-1.8.4.tgz",
+ "integrity": "sha512-9zjvACKE4W0kEb8OQtXzpizKhf6zfFOG/Z1TEUjSJn4Z4rintuAHo8y/FpCUhTWHMmPe8E+Fko+/tiXVM+5jOw=="
+ },
+ "node_modules/vue-eslint-parser": {
+ "version": "9.4.3",
+ "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz",
+ "integrity": "sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.3.4",
+ "eslint-scope": "^7.1.1",
+ "eslint-visitor-keys": "^3.3.0",
+ "espree": "^9.3.1",
+ "esquery": "^1.4.0",
+ "lodash": "^4.17.21",
+ "semver": "^7.3.6"
+ },
+ "engines": {
+ "node": "^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/mysticatea"
+ },
+ "peerDependencies": {
+ "eslint": ">=6.0.0"
+ }
+ },
+ "node_modules/vue-eslint-parser/node_modules/eslint-scope": {
+ "version": "7.2.2",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
+ "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
+ "dev": true,
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/vue-eslint-parser/node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/vue-eslint-parser/node_modules/espree": {
+ "version": "9.6.1",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
+ "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
+ "dev": true,
+ "dependencies": {
+ "acorn": "^8.9.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^3.4.1"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/vue-router": {
+ "version": "4.4.5",
+ "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.4.5.tgz",
+ "integrity": "sha512-4fKZygS8cH1yCyuabAXGUAsyi1b2/o/OKgu/RUb+znIYOxPRxdkytJEx+0wGcpBE1pX6vUgh5jwWOKRGvuA/7Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/devtools-api": "^6.6.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/posva"
+ },
+ "peerDependencies": {
+ "vue": "^3.2.0"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
+ "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ }
+ }
+}
diff --git a/front/package.json b/front/package.json
new file mode 100644
index 00000000..02ec3866
--- /dev/null
+++ b/front/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "youtube-dl-server",
+ "version": "0.0.0",
+ "private": true,
+ "scripts": {
+ "serve": "vite serve --host",
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview",
+ "lint": "eslint ."
+ },
+ "dependencies": {
+ "bootstrap": "^5.3.3",
+ "lodash": "^4.17.21",
+ "vue": "^3.4.27",
+ "vue-cookies": "^1.8.4",
+ "vue-router": "^4.3.2"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^5.0.4",
+ "eslint": "^9.3.0",
+ "eslint-config-google": "^0.14.0",
+ "eslint-plugin-vue": "^9.26.0",
+ "vite": "^5.2.11"
+ }
+}
diff --git a/front/src/App.vue b/front/src/App.vue
new file mode 100644
index 00000000..7d80ccf9
--- /dev/null
+++ b/front/src/App.vue
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
diff --git a/front/src/assets/style.css b/front/src/assets/style.css
new file mode 100644
index 00000000..b21836ca
--- /dev/null
+++ b/front/src/assets/style.css
@@ -0,0 +1,79 @@
+html,
+body {
+ height: 100%;
+ background-color: #002b36;
+ color: #fff;
+ margin: 0;
+}
+
+.table-responsive {
+ overflow-x: auto;
+ max-width: 100%;
+}
+
+.table-responsive table {
+ table-layout: fixed;
+}
+
+.table-responsive td {
+ white-space: pre-wrap;
+ word-wrap: break-word;
+}
+
+.content {
+ min-height: 100%;
+ margin-top: -50px;
+ padding-top: 70px;
+ margin-bottom: -70px;
+ padding-bottom: 70px;
+}
+
+.modal-dialog a {
+ overflow-wrap: break-word;
+}
+
+nav {
+ background-color: #001b26;
+}
+
+div.input-group>select.custom-select {
+ flex: 0.1 1 100px;
+ background-color: #d1e4eb;
+ color: #000;
+ border: 1px solid #c1c1c1;
+ z-index: 100;
+}
+
+
+div.input-group>input.form-control {
+ border: 1px solid #c1c1c1;
+ box-shadow: inset 1px 2px 3px rgba(0, 0, 0, 0.2);
+}
+
+#button-submit {
+ margin-left: -3px;
+ padding-left: 15px;
+ box-shadow: none;
+}
+
+.nav-item .badge {
+ margin-right: 2px;
+}
+
+.nav-link.router-link-active {
+ color: white;
+}
+
+.stats-link {
+ color: white;
+ text-decoration: none;
+}
+
+.footer {
+ width: 100%;
+}
+
+.text-muted {
+ --bs-text-opacity: 1;
+ color: #6c757d !important;
+}
diff --git a/front/src/components/FileTreeItem.vue b/front/src/components/FileTreeItem.vue
new file mode 100644
index 00000000..91cdfaf0
--- /dev/null
+++ b/front/src/components/FileTreeItem.vue
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ ' '.repeat(depth*2) }}{{ depth > 0 ? '↳ ' : ''}}{{ item.name }}
+ {{ item.modified }}
+ {{ item.created }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ ' '.repeat(depth*2) }}{{ depth > 0 ? '↳ ' : ''}}{{ item.name }}
+ {{ prettySize(item.size) }}
+ {{ item.modified }}
+ {{ item.created }}
+
+
+
+
diff --git a/front/src/components/Finished.vue b/front/src/components/Finished.vue
new file mode 100644
index 00000000..fe469493
--- /dev/null
+++ b/front/src/components/Finished.vue
@@ -0,0 +1,140 @@
+
+
+
+
+
+
+
Finished Files
+
+
+
+
+
+ Action
+ Name
+ ↑
+ ↓
+
+ Size
+ ↑
+ ↓
+
+ Upload Date
+ ↑
+ ↓
+
+ Fetch Date
+ ↑
+ ↓
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/front/src/components/Footer.vue b/front/src/components/Footer.vue
new file mode 100644
index 00000000..4b995865
--- /dev/null
+++ b/front/src/components/Footer.vue
@@ -0,0 +1,52 @@
+
+
+
+
+
diff --git a/front/src/components/Header.vue b/front/src/components/Header.vue
new file mode 100644
index 00000000..18d75fbd
--- /dev/null
+++ b/front/src/components/Header.vue
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
{{ prettyModule }}
+
+
+
+
+
+
+ Home
+
+
+ Logs
+
+
+ Finished
+
+
+
+
+
+ Stats :
+
+ {{ stats.queue }}
+ {{ stats.queue }} | {{ stats.pending }}
+
+
+ {{ stats.running }}/{{ server_info.download_workers_count }}
+
+
+ {{ stats.completed }}
+
+
+ {{ stats.aborted }}
+
+
+ {{ stats.failed }}
+
+
+
+
+
+
+
+
+
diff --git a/front/src/components/Home.vue b/front/src/components/Home.vue
new file mode 100644
index 00000000..158501be
--- /dev/null
+++ b/front/src/components/Home.vue
@@ -0,0 +1,320 @@
+
+
+
+
+
+
+
+
+
{{ server_info.ydl_module_name }}
+
Enter a video URL to download the video to the server. URL can be to YouTube or any
+ other supported site . The server will automatically download the highest quality version available.
+
+
+
+
+
+
+ {{ format_name }}
+
+
+
+
+ Download
+
+
+ Inspect
+
+
+ Loading...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/front/src/components/Logs.vue b/front/src/components/Logs.vue
new file mode 100644
index 00000000..005cbbb8
--- /dev/null
+++ b/front/src/components/Logs.vue
@@ -0,0 +1,233 @@
+
+
+
+
+
diff --git a/front/src/main.js b/front/src/main.js
new file mode 100644
index 00000000..774eebbe
--- /dev/null
+++ b/front/src/main.js
@@ -0,0 +1,28 @@
+import { createApp } from 'vue';
+import App from './App.vue';
+import VueCookies from 'vue-cookies'
+import { createRouter, createWebHashHistory } from 'vue-router';
+
+import 'bootstrap/dist/css/bootstrap.css';
+
+import './assets/style.css';
+import Logs from './components/Logs.vue';
+import Home from './components/Home.vue';
+import Finished from './components/Finished.vue';
+
+const routes = [
+ { path: '/', component: Home },
+ { path: '/home', component: Home },
+ { path: '/logs', component: Logs },
+ { path: '/finished', component: Finished },
+];
+
+const router = createRouter({
+ history: createWebHashHistory(),
+ routes,
+});
+const app = createApp(App);
+app.use(router);
+app.use(VueCookies);
+
+app.mount('#app');
diff --git a/front/src/utils.js b/front/src/utils.js
new file mode 100644
index 00000000..3ce1d326
--- /dev/null
+++ b/front/src/utils.js
@@ -0,0 +1,23 @@
+import { get } from 'lodash';
+
+function getAPIUrl(path, env) {
+ const VITE_YOUTUBE_DL_SERVER_API_URL = get(env, 'VITE_YOUTUBE_DL_SERVER_API_URL', '');
+ if (VITE_YOUTUBE_DL_SERVER_API_URL) {
+ if (VITE_YOUTUBE_DL_SERVER_API_URL.endsWith('/')) {
+ return `${VITE_YOUTUBE_DL_SERVER_API_URL}${path}`;
+ }
+ return `${VITE_YOUTUBE_DL_SERVER_API_URL}/${path}`;
+ }
+ return path;
+}
+
+function saveConfig(key, value) {
+ $cookies.set(key, value, -1, '/', '', true, 'Strict');
+}
+
+function getConfig(key, defaultValue) {
+ return $cookies.get(key) || defaultValue;
+}
+
+
+export { getAPIUrl, saveConfig, getConfig };
diff --git a/front/vite.config.js b/front/vite.config.js
new file mode 100644
index 00000000..ea461e5f
--- /dev/null
+++ b/front/vite.config.js
@@ -0,0 +1,15 @@
+import {fileURLToPath, URL} from 'node:url';
+
+import {defineConfig} from 'vite';
+import vue from '@vitejs/plugin-vue';
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [vue()],
+ base: '',
+ resolve: {
+ alias: {
+ '@': fileURLToPath(new URL('./src', import.meta.url)),
+ },
+ },
+});
diff --git a/hooks/build b/hooks/build
new file mode 100644
index 00000000..862481a9
--- /dev/null
+++ b/hooks/build
@@ -0,0 +1,16 @@
+#!/bin/bash
+
+
+YOUTUBE_DL="youtube-dl"
+ATOMICPARSLEY=0
+
+IFS='_' read -ra DOCKER_TAGS <<< "$DOCKER_TAG"
+
+if [ "${DOCKER_TAGS[0]}" == "youtube-dl" ] || [ "${DOCKER_TAGS[0]}" == "youtube-dlc" ]; then
+ YOUTUBE_DL="${DOCKER_TAGS[0]}"
+fi
+if [ "${#DOCKER_TAGS[1]}" -gt 0 ] && [ "${DOCKER_TAGS[1]}" == "atomicparsley" ]; then
+ ATOMICPARSLEY=1
+fi
+
+docker build --build-arg YOUTUBE_DL=$YOUTUBE_DL --build-arg ATOMICPARSLEY=$ATOMICPARSLEY -f $DOCKERFILE_PATH -t $IMAGE_NAME .
\ No newline at end of file
diff --git a/index.html b/index.html
deleted file mode 100644
index 4b8da6f5..00000000
--- a/index.html
+++ /dev/null
@@ -1,77 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
- youtube-dl
-
-
-
-
-
-
-
youtube-dl
-
Enter a video url to download the video to the server. Url can be to YouTube or any
- other supported site . The server will automatically download the highest quality version available.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/requirements.txt b/requirements.txt
index 290e5b21..b5bc3812 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,8 @@
-bottle
-youtube-dl
+starlette==0.41.3
+uvicorn==0.32.0
+aiofiles==24.1.0
+Jinja2==3.1.4
+PyYAML==6.0.2
+youtube-dl==2021.12.17
+yt-dlp[default]==2024.11.18
+python-multipart==0.0.16
diff --git a/static/style.css b/static/style.css
deleted file mode 100644
index 242ff3f8..00000000
--- a/static/style.css
+++ /dev/null
@@ -1,11 +0,0 @@
-body {
- background-color: #002b36;
-}
-
-.container {
- height: 100vh;
-}
-
-div.input-group>select.custom-select {
- flex: 0.1 1 100px;
-}
diff --git a/ydl_server/__init__.py b/ydl_server/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/ydl_server/config.py b/ydl_server/config.py
new file mode 100644
index 00000000..e6b3ee64
--- /dev/null
+++ b/ydl_server/config.py
@@ -0,0 +1,97 @@
+import os
+import yaml
+import shutil
+
+YDL_FORMATS = {
+ "Video": {
+ "video/best": "Best",
+ "video/bestvideo": "Best Video",
+ "video/mp4": "MP4",
+ "video/flv": "Flash Video (FLV)",
+ "video/webm": "WebM",
+ "video/ogg": "Ogg",
+ "video/mkv": "Matroska (MKV)",
+ "video/avi": "AVI",
+ },
+ "Audio": {
+ "bestaudio/best": "Best Audio",
+ "audio/aac": "AAC",
+ "audio/flac": "FLAC",
+ "audio/mp3": "MP3",
+ "audio/m4a": "M4A",
+ "audio/opus": "Opus",
+ "audio/vorbis": "Vorbis",
+ "audio/wav": "WAV",
+ },
+}
+
+
+def get_ydl_formats(app_config):
+ if len(app_config.get("profiles", {}).keys()) > 0:
+ YDL_FORMATS["Profiles"] = {
+ f"profile/{k}": v.get("name") for k, v in app_config.get("profiles").items()
+ }
+ return YDL_FORMATS
+
+
+def copy_default_config(config_file_path):
+ try:
+ shutil.copy("./default_config.yml", config_file_path)
+ except Exception as e:
+ raise Exception(
+ "Error copying default config file to {}:\n{}".format(
+ config_file_path, str(e)
+ )
+ )
+
+
+def get_config_file_path():
+ config_path = os.environ.get("YDL_CONFIG_PATH", os.getcwd())
+ if "." in os.path.basename(config_path):
+ return config_path
+ return os.path.join(config_path, "config.yml")
+
+
+def load_config():
+ config = None
+ config_file_path = get_config_file_path()
+ print("Using configuration file {}".format(config_file_path))
+
+ if not os.path.isfile(config_file_path):
+ print(
+ "{} does not exist, creating it from default values".format(
+ config_file_path
+ )
+ )
+ try:
+ copy_default_config(config_file_path)
+ except Exception:
+ print("Error copying default config file, loading it directly")
+ config_file_path = "./default_config.yml"
+ with open(config_file_path) as configfile:
+ config = yaml.load(configfile, Loader=yaml.SafeLoader)
+
+ return config
+
+
+def get_finished_path():
+ finished_path = []
+ for s in app_config["ydl_options"].get("output").split("/"):
+ if "%" in s and "%%" not in s:
+ break
+ finished_path.append(s)
+ finished_path = "/".join(finished_path) + "/"
+ if not os.path.isdir(finished_path):
+ os.mkdir(finished_path, 0o755)
+ return finished_path
+
+
+app_config = load_config()
+
+if (
+ app_config is None
+ or app_config.get("ydl_server") is None
+ or app_config.get("ydl_options") is None
+ or app_config["ydl_options"].get("output") is None
+):
+ raise Exception("Invalid configuration file")
diff --git a/ydl_server/db.py b/ydl_server/db.py
new file mode 100644
index 00000000..b1e43105
--- /dev/null
+++ b/ydl_server/db.py
@@ -0,0 +1,438 @@
+import sqlite3
+import re
+import datetime
+
+from ydl_server.config import app_config
+
+STATUS_NAME = ["Running", "Completed", "Failed", "Pending", "Aborted"]
+
+SCHEMA_VERSION = 1
+
+class Actions:
+ DOWNLOAD = 1
+ PURGE_LOGS = 2
+ INSERT = 3
+ UPDATE = 4
+ RESUME = 5
+ SET_NAME = 6
+ SET_STATUS = 7
+ SET_LOG = 8
+ CLEAN_LOGS = 9
+ SET_PID = 10
+ DELETE_LOG = 11
+ DELETE_LOG_SAFE = 12
+
+
+class JobType:
+ YDL_DOWNLOAD = 0
+ YDL_UPDATE = 1
+
+
+class Job:
+ RUNNING = 0
+ COMPLETED = 1
+ FAILED = 2
+ PENDING = 3
+ ABORTED = 4
+
+ def __init__(self, name, status, log, jobtype, format=None, url=None, id=-1, pid=0):
+ self.id = id
+ self.name = name
+ self.status = status
+ self.log = log
+ self.last_update = ""
+ self.format = format
+ self.type = jobtype
+ self.url = url
+ self.pid = pid
+
+ @staticmethod
+ def clean_logs(logs):
+ if not logs:
+ return logs
+ clean = ""
+ for line in logs.split("\n"):
+ line = re.sub(".*\r", "", line)
+ if len(line) > 0:
+ clean = "%s%s\n" % (clean, line)
+ return clean
+
+
+class JobsDB:
+
+ @staticmethod
+ def init():
+ conn = sqlite3.connect(
+ "file://%s" % app_config["ydl_server"].get("metadata_db_path"), uri=True
+ )
+ version = JobsDB.db_version(conn)
+ JobsDB.migrate(conn, version)
+ conn.close()
+
+
+ @staticmethod
+ def db_version(conn):
+ cursor = conn.cursor()
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='jobs'")
+ table_exists = cursor.fetchone()
+ if not table_exists:
+ return -1
+ cursor.execute("PRAGMA user_version;")
+ version = int(cursor.fetchone()[0])
+ return version
+
+ @staticmethod
+ def migrate(conn, version):
+ print("Migrating database from version %d" % version)
+ match version:
+ case -1:
+ print("No jobs table found, creating")
+ JobsDB.create(conn)
+ return
+ case 0:
+ cursor = conn.cursor()
+ cursor.execute("PRAGMA table_info('jobs')")
+ columns = [row[1] for row in cursor.fetchall()]
+ if set(columns) != set(
+ [
+ "id",
+ "name",
+ "status",
+ "format",
+ "log",
+ "last_update",
+ "type",
+ "url",
+ "pid",
+ ]
+ ):
+ print("Outdated jobs table, cleaning up and recreating")
+ cursor.execute("DROP TABLE if exists jobs;")
+ conn.commit()
+ JobsDB.create(conn)
+ return
+ case SCHEMA_VERSION:
+ cursor = conn.cursor()
+ cursor.execute(
+ """
+ PRAGMA user_version = """ + str(SCHEMA_VERSION) + """;
+ """
+ )
+ conn.commit()
+ return
+ return JobsDB.migrate(conn, version + 1)
+
+
+ @staticmethod
+ def create(conn):
+ cursor = conn.cursor()
+ cursor.execute(
+ """
+ CREATE TABLE if not exists jobs
+ (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ status INTEGER NOT NULL,
+ log TEXT,
+ format TEXT,
+ last_update DATETIME DEFAULT CURRENT_TIMESTAMP,
+ type INTEGER NOT NULL,
+ url TEXT,
+ pid INTEGER
+ );
+ """
+ )
+ cursor.execute(
+ """
+ PRAGMA user_version = """ + str(SCHEMA_VERSION) + """;
+ """
+ )
+ conn.commit()
+
+ @staticmethod
+ def convert_datetime_to_tz(dt):
+ dt = datetime.datetime.strptime("{} +0000".format(dt), "%Y-%m-%d %H:%M:%S %z")
+ return dt.astimezone().strftime("%Y-%m-%d %H:%M:%S")
+
+ def __init__(self, readonly=True):
+ self.conn = sqlite3.connect(
+ "file://%s%s"
+ % (
+ app_config["ydl_server"].get("metadata_db_path"),
+ "?mode=ro" if readonly else "",
+ ),
+ uri=True,
+ )
+
+ def close(self):
+ self.conn.close()
+
+ def insert_job(self, job):
+ cursor = self.conn.cursor()
+ cursor.execute(
+ """
+ INSERT INTO jobs
+ (name, status, log, format, type, url, pid)
+ VALUES
+ (?, ?, ?, ?, ?, ?, ?);
+ """,
+ (
+ job.name,
+ str(job.status),
+ job.log,
+ job.format,
+ str(job.type),
+ "\n".join(job.url),
+ job.pid,
+ ),
+ )
+ job.id = cursor.lastrowid
+ self.conn.commit()
+
+ def update_job(self, job):
+ cursor = self.conn.cursor()
+ cursor.execute(
+ """
+ UPDATE jobs
+ SET status = ?, log = ?, last_update = datetime() \
+ WHERE id = ?;
+ """,
+ (str(job.status), job.log, str(job.id)),
+ )
+ self.conn.commit()
+
+ def set_job_status(self, job_id, status):
+ cursor = self.conn.cursor()
+ cursor.execute(
+ """
+ UPDATE jobs
+ SET status = ?, last_update = datetime() \
+ WHERE id = ?;
+ """,
+ (str(status), str(job_id)),
+ )
+ self.conn.commit()
+
+ def set_job_pid(self, job_id, pid):
+ cursor = self.conn.cursor()
+ cursor.execute(
+ """
+ UPDATE jobs
+ SET pid = ?, last_update = datetime() \
+ WHERE id = ?;
+ """,
+ (str(pid), str(job_id)),
+ )
+ self.conn.commit()
+
+ def set_job_log(self, job_id, log):
+ cursor = self.conn.cursor()
+ cursor.execute(
+ """
+ UPDATE jobs
+ SET log = ?, last_update = datetime() \
+ WHERE id = ?;
+ """,
+ (log, str(job_id)),
+ )
+ self.conn.commit()
+
+ def set_job_name(self, job_id, name):
+ cursor = self.conn.cursor()
+ cursor.execute(
+ """
+ UPDATE jobs
+ SET name = ?, last_update = datetime() \
+ WHERE id = ?;
+ """,
+ (name, str(job_id)),
+ )
+ self.conn.commit()
+
+ def purge_jobs(self):
+ cursor = self.conn.cursor()
+ cursor.execute("DELETE FROM jobs;")
+ self.conn.commit()
+ self.conn.execute("VACUUM")
+
+ def delete_job_safe(self, job_id):
+ cursor = self.conn.cursor()
+ cursor.execute(
+ "DELETE FROM jobs WHERE id = ? AND ( status = ? OR status = ? );",
+ (str(job_id), Job.ABORTED, Job.FAILED),
+ )
+ self.conn.commit()
+ self.conn.execute("VACUUM")
+
+ def delete_job(self, job_id):
+ cursor = self.conn.cursor()
+ cursor.execute(
+ "DELETE FROM jobs WHERE id = ?;",
+ (str(job_id),),
+ )
+ self.conn.commit()
+ self.conn.execute("VACUUM")
+
+ def clean_old_jobs(self, limit=10):
+ cursor = self.conn.cursor()
+ cursor.execute(
+ """
+ SELECT last_update
+ FROM jobs
+ ORDER BY last_update DESC
+ LIMIT ?;
+ """,
+ (str(limit),),
+ )
+ rows = list(cursor.fetchall())
+ if len(rows) > 0:
+ cursor.execute(
+ "DELETE FROM jobs WHERE last_update < ? AND status != ? and status != ?;",
+ (rows[-1][0], Job.PENDING, Job.RUNNING),
+ )
+ self.conn.commit()
+ self.conn.execute("VACUUM")
+
+ def get_job_by_id(self, job_id):
+ cursor = self.conn.cursor()
+ cursor.execute(
+ """
+ SELECT
+ id, name, status, log, last_update, format, type, url, pid
+ FROM
+ jobs
+ WHERE id = ?;
+ """,
+ (job_id,),
+ )
+ row = cursor.fetchone()
+ if not row:
+ return
+ (
+ job_id,
+ name,
+ status,
+ log,
+ last_update,
+ format,
+ jobtype,
+ url,
+ pid,
+ ) = row
+ return {
+ "id": job_id,
+ "name": name,
+ "status": STATUS_NAME[status],
+ "log": log,
+ "format": format,
+ "last_update": JobsDB.convert_datetime_to_tz(last_update),
+ "type": jobtype,
+ "urls": url.split("\n"),
+ "pid": pid,
+ }
+
+ def get_jobs_with_logs(self, limit=50, status=None):
+ cursor = self.conn.cursor()
+ status = STATUS_NAME.index(status.capitalize()) if status and status.capitalize() in STATUS_NAME else -1
+ if status >= 0:
+ cursor.execute(
+ """
+ SELECT
+ id, name, status, log, last_update, format, type, url, pid
+ FROM
+ jobs
+ WHERE
+ status = ?
+ ORDER BY last_update DESC LIMIT ?;
+ """,
+ (status, str(limit),),
+ )
+ else:
+ cursor.execute(
+ """
+ SELECT
+ id, name, status, log, last_update, format, type, url, pid
+ FROM
+ jobs
+ ORDER BY last_update DESC LIMIT ?;
+ """,
+ (str(limit),),
+ )
+ rows = []
+ for (
+ job_id,
+ name,
+ status,
+ log,
+ last_update,
+ format,
+ jobtype,
+ url,
+ pid,
+ ) in cursor.fetchall():
+ rows.append(
+ {
+ "id": job_id,
+ "name": name,
+ "status": STATUS_NAME[status],
+ "log": log,
+ "format": format,
+ "last_update": JobsDB.convert_datetime_to_tz(last_update),
+ "type": jobtype,
+ "urls": url.split("\n"),
+ "pid": pid,
+ }
+ )
+ return rows
+
+ def get_jobs(self, limit=50, status=None):
+ cursor = self.conn.cursor()
+ status = STATUS_NAME.index(status.capitalize()) if status and status.capitalize() in STATUS_NAME else -1
+ if status >= 0:
+ cursor.execute(
+ """
+ SELECT
+ id, name, status, last_update, format, type, url, pid
+ FROM
+ jobs
+ WHERE
+ status = ?
+ ORDER BY last_update DESC LIMIT ?;
+ """,
+ (status, str(limit),),
+ )
+ else:
+ cursor.execute(
+ """
+ SELECT
+ id, name, status, last_update, format, type, url, pid
+ FROM
+ jobs
+ ORDER BY last_update DESC LIMIT ?;
+ """,
+ (str(limit),),
+ )
+ rows = []
+ for (
+ job_id,
+ name,
+ status,
+ last_update,
+ format,
+ jobtype,
+ url,
+ pid,
+ ) in cursor.fetchall():
+ rows.append(
+ {
+ "id": job_id,
+ "name": name,
+ "status": STATUS_NAME[status],
+ "format": format,
+ "last_update": JobsDB.convert_datetime_to_tz(last_update),
+ "type": jobtype,
+ "urls": url.split("\n"),
+ "pid": pid,
+ }
+ )
+ return rows
diff --git a/ydl_server/jobshandler.py b/ydl_server/jobshandler.py
new file mode 100644
index 00000000..febebe80
--- /dev/null
+++ b/ydl_server/jobshandler.py
@@ -0,0 +1,68 @@
+from queue import Queue, Empty
+from threading import Thread
+from ydl_server.db import JobsDB, Actions
+
+
+class JobsHandler:
+ def __init__(self, app_config):
+ self.queue = Queue()
+ self.thread = None
+ self.done = False
+ self.app_config = app_config
+
+ def start(self, dl_queue):
+ self.thread = Thread(target=self.worker, args=(dl_queue,))
+ self.thread.start()
+
+ def stop(self):
+ self.done = True
+
+ def put(self, obj):
+ self.queue.put(obj)
+
+ def finish(self):
+ self.done = True
+
+ def worker(self, dl_queue):
+ db = JobsDB(readonly=False)
+ while not self.done:
+ try:
+ action, job = self.queue.get(timeout=1)
+ except Empty:
+ continue
+ if action == Actions.PURGE_LOGS:
+ db.purge_jobs()
+ elif action == Actions.INSERT:
+ db.clean_old_jobs(
+ self.app_config["ydl_server"].get("max_log_entries", 100) - 1
+ )
+ db.insert_job(job)
+ dl_queue.put(job)
+ elif action == Actions.UPDATE:
+ db.update_job(job)
+ elif action == Actions.RESUME:
+ db.update_job(job)
+ dl_queue.put(job)
+ elif action == Actions.SET_NAME:
+ job_id, name = job
+ db.set_job_name(job_id, name)
+ elif action == Actions.SET_LOG:
+ job_id, log = job
+ db.set_job_log(job_id, log)
+ elif action == Actions.SET_STATUS:
+ job_id, status = job
+ db.set_job_status(job_id, status)
+ elif action == Actions.SET_PID:
+ job_id, pid = job
+ db.set_job_pid(job_id, pid)
+ elif action == Actions.CLEAN_LOGS:
+ db.clean_old_jobs()
+ elif action == Actions.DELETE_LOG_SAFE:
+ db.delete_job_safe(job["id"])
+ elif action == Actions.DELETE_LOG:
+ db.delete_job(job["id"])
+ self.queue.task_done()
+
+ def join(self):
+ if self.thread is not None:
+ return self.thread.join()
diff --git a/ydl_server/routes.py b/ydl_server/routes.py
new file mode 100644
index 00000000..4be1b76f
--- /dev/null
+++ b/ydl_server/routes.py
@@ -0,0 +1,65 @@
+from pathlib import Path
+
+from ydl_server import views
+from ydl_server.config import get_finished_path
+
+from starlette.routing import Route, Mount
+from starlette.staticfiles import StaticFiles
+
+static = StaticFiles(directory=str(Path(__file__).parent / "static"), html=True)
+
+finished_files = StaticFiles(directory=get_finished_path())
+
+routes = [
+ Route("/api/extractors", views.api_list_extractors, name="api_list_extractors"),
+ Route("/api/formats", views.api_list_formats, name="api_list_formats"),
+ Route("/api/info", views.api_server_info, name="api_server_info"),
+ Route("/api/downloads/stats", views.api_queue_size, name="api_queue_size"),
+ Route("/api/downloads", views.api_logs, name="api_logs"),
+ Route("/api/downloads/clean", views.api_logs_clean, name="api_logs_clean"),
+ Route(
+ "/api/downloads",
+ views.api_logs_purge,
+ name="api_logs_purge",
+ methods=["DELETE"],
+ ),
+ Route(
+ "/api/downloads",
+ views.api_queue_download,
+ name="api_queue_download",
+ methods=["POST"],
+ ),
+ Route(
+ "/api/metadata",
+ views.api_metadata_fetch,
+ name="api_metadata_fetch",
+ methods=["POST"],
+ ),
+ Route("/api/finished", views.api_finished, name="api_finished", methods=["GET"]),
+ Route(
+ "/api/finished/{fname:path}",
+ views.api_delete_file,
+ name="api_delete_file",
+ methods=["DELETE"],
+ ),
+ Route(
+ "/api/jobs/{job_id:str}/stop",
+ views.api_jobs_stop,
+ name="api_jobs_stop",
+ methods=["POST"],
+ ),
+ Route(
+ "/api/jobs/{job_id:str}/retry",
+ views.api_jobs_retry,
+ name="api_jobs_retry",
+ methods=["POST"],
+ ),
+ Route(
+ "/api/jobs/{job_id:str}",
+ views.api_jobs_delete,
+ name="api_jobs_delete",
+ methods=["DELETE"],
+ ),
+ Mount("/api/finished/", finished_files, name="api_finished"),
+ Mount("/", static, name="static"),
+]
diff --git a/ydl_server/views.py b/ydl_server/views.py
new file mode 100644
index 00000000..1a030fc9
--- /dev/null
+++ b/ydl_server/views.py
@@ -0,0 +1,225 @@
+from starlette.responses import JSONResponse
+
+from operator import itemgetter
+from pathlib import Path
+from ydl_server.config import app_config, get_finished_path, get_ydl_formats
+from ydl_server.db import JobsDB, Job, Actions, JobType
+from datetime import datetime
+import os
+import signal
+import shutil
+
+
+def build_finished_tree(root_dir):
+ matches = root_dir.glob("*")
+
+ files = [
+ {
+ "name": f1.name,
+ "modified": datetime.fromtimestamp(f1.stat().st_mtime).strftime("%H:%m %D"),
+ "created": datetime.fromtimestamp(f1.stat().st_ctime).strftime("%H:%m %D"),
+ "size": f1.stat().st_size if not f1.is_dir() else None,
+ "directory": f1.is_dir(),
+ "children": sorted(build_finished_tree(f1), key=itemgetter("modified"), reverse=True)
+ if f1.is_dir()
+ else None,
+ }
+ for f1 in matches
+ if not f1.name.startswith(".")
+ ]
+ return files
+
+async def api_finished(request):
+ return JSONResponse(build_finished_tree(Path(get_finished_path())))
+
+
+async def api_delete_file(request):
+ fname = request.path_params["fname"]
+ if not fname:
+ return JSONResponse({"success": False, "message": "No filename specified"})
+ fname = os.path.realpath(os.path.join(get_finished_path(), fname))
+ if os.path.commonprefix((fname, get_finished_path())) != get_finished_path():
+ return JSONResponse({"success": False, "message": "Invalid filename"})
+ fname = Path(fname)
+ try:
+ if fname.is_dir():
+ shutil.rmtree(fname)
+ else:
+ fname.unlink()
+ except OSError as e:
+ print(e)
+ return JSONResponse(
+ {"success": False, "message": "Could not delete the specified file"}
+ )
+
+ return JSONResponse({"success": True, "message": "File deleted"})
+
+
+async def api_list_extractors(request):
+ return JSONResponse(request.app.state.ydlhandler.ydl_extractors)
+
+
+async def api_server_info(request):
+ return JSONResponse(
+ {
+ "ydl_module_name": request.app.state.ydlhandler.ydl_module_name,
+ "ydl_module_version": request.app.state.ydlhandler.ydl_version,
+ "ydl_module_website": request.app.state.ydlhandler.ydl_website,
+ "ydls_version": request.app.state.ydlhandler.ydls_version,
+ "ydls_release_date": request.app.state.ydlhandler.ydls_release_date,
+ "download_workers_count": request.app.state.ydlhandler.download_workers_count,
+ }
+ )
+
+
+async def api_list_formats(request):
+ return JSONResponse(
+ {
+ "ydl_formats": get_ydl_formats(app_config),
+ "ydl_default_format": app_config["ydl_server"].get(
+ "default_format", "video/best"
+ ),
+ }
+ )
+
+
+async def api_queue_size(request):
+ db = JobsDB(readonly=True)
+ jobs = db.get_jobs(app_config["ydl_server"].get("max_log_entries", 100))
+ return JSONResponse(
+ {
+ "success": True,
+ "stats": {
+ "queue": request.app.state.ydlhandler.queue.qsize(),
+ "pending": len([job for job in jobs if job["status"] == "Pending"]),
+ "running": len([job for job in jobs if job["status"] == "Running"]),
+ "completed": len([job for job in jobs if job["status"] == "Completed"]),
+ "failed": len([job for job in jobs if job["status"] == "Failed"]),
+ "aborted": len([job for job in jobs if job["status"] == "Aborted"]),
+ },
+ }
+ )
+
+
+async def api_logs(request):
+ db = JobsDB(readonly=True)
+ if request.query_params.get("show_logs", "1") in ["1", "true"]:
+ return JSONResponse(
+ db.get_jobs_with_logs(
+ app_config["ydl_server"].get("max_log_entries", 100),
+ request.query_params.get("status", None)
+ )
+ )
+ return JSONResponse(
+ db.get_jobs(app_config["ydl_server"].get("max_log_entries", 100))
+ )
+
+
+async def api_logs_purge(request):
+ request.app.state.jobshandler.put((Actions.PURGE_LOGS, None))
+ return JSONResponse({"success": True})
+
+
+async def api_logs_clean(request):
+ request.app.state.jobshandler.put((Actions.CLEAN_LOGS, None))
+ return JSONResponse({"success": True})
+
+
+async def api_jobs_stop(request):
+ db = JobsDB(readonly=True)
+ job_id = request.path_params["job_id"]
+ job = db.get_job_by_id(job_id)
+
+ if not job:
+ return JSONResponse({"success": False}, status_code=404)
+ if job["status"] == "Pending":
+ print("Cancelling pending job")
+ request.app.state.jobshandler.put(
+ (Actions.SET_STATUS, (job["id"], Job.ABORTED))
+ )
+ return JSONResponse({"success": True})
+ if job["status"] == "Running" and int(job["pid"]) != 0:
+ print("Stopping running job", job["pid"])
+ try:
+ print(os.kill(job["pid"], signal.SIGINT))
+ except ProcessLookupError:
+ print("Process already dead")
+ return JSONResponse({"success": True})
+ if int(job["pid"]) == 0:
+ request.app.state.jobshandler.put(
+ (Actions.SET_STATUS, (job["id"], Job.ABORTED))
+ )
+ return JSONResponse({"success": True})
+ return JSONResponse({"success": False})
+
+
+async def api_jobs_retry(request):
+ db = JobsDB(readonly=True)
+ job_id = request.path_params["job_id"]
+ job = db.get_job_by_id(job_id)
+ if not job:
+ return JSONResponse({"success": False}, status_code=404)
+
+ new_job = Job(
+ job["name"], Job.PENDING, "", JobType.YDL_DOWNLOAD, job["format"], job["urls"]
+ )
+
+ request.app.state.jobshandler.put((Actions.DELETE_LOG_SAFE, job))
+ request.app.state.jobshandler.put((Actions.INSERT, new_job))
+
+ return JSONResponse({"success": True})
+
+async def api_jobs_delete(request):
+ job_id = request.path_params["job_id"]
+ if job_id is not None:
+ request.app.state.jobshandler.put((Actions.DELETE_LOG, {'id': job_id}))
+ return JSONResponse({"success": True})
+ return JSONResponse({"success": False})
+
+async def api_queue_download(request):
+ if request.headers.get("Content-Type") == "application/x-www-form-urlencoded":
+ data = await request.form()
+ else:
+ data = await request.json()
+ url = data.get("url")
+ urls = data.get("urls", [])
+ profile = data.get("profile")
+ audio_format = data.get("audio_format")
+ format_str = data.get("format")
+
+ if profile:
+ format_str = ','.join([format_str, profile])
+ if audio_format:
+ format_str = ',audio/'.join([format_str, audio_format])
+ options = {"format": format_str}
+
+ if url:
+ urls.append(url)
+
+ if len(urls) == 0:
+ return JSONResponse(
+ {"success": False, "error": "'url' and 'urls' query parameters omitted"}
+ )
+
+ job = Job(
+ ", ".join(urls), Job.PENDING, "", JobType.YDL_DOWNLOAD, format_str, urls
+ )
+ request.app.state.jobshandler.put((Actions.INSERT, job))
+
+ print("Added url " + ",".join(urls) + " to the download queue")
+ return JSONResponse({"success": True, "urls": urls, "options": options})
+
+
+async def api_metadata_fetch(request):
+ if request.headers.get("Content-Type") == "application/x-www-form-urlencoded":
+ data = await request.form()
+ else:
+ data = await request.json()
+ url = data.get("url")
+ urls = data.get("urls", [])
+ if url:
+ urls.append(url)
+ rc, stdout = request.app.state.ydlhandler.fetch_metadata(urls)
+ if rc == 0:
+ return JSONResponse(stdout)
+ return JSONResponse({"success": False}, status_code=404)
diff --git a/ydl_server/ydlhandler.py b/ydl_server/ydlhandler.py
new file mode 100644
index 00000000..1d13e75c
--- /dev/null
+++ b/ydl_server/ydlhandler.py
@@ -0,0 +1,292 @@
+import os
+from queue import Queue, Empty
+from threading import Thread
+import io
+import importlib
+import json
+from time import sleep
+from datetime import datetime
+from subprocess import Popen, PIPE, STDOUT
+
+from ydl_server.db import JobsDB, Job, Actions, JobType
+
+
+YDL_MODULES = ["youtube_dl", "youtube_dlc", "yt_dlp"]
+
+
+def get_ydl_website(ydl_module_name):
+ import pip._internal.commands.show as pipshow
+
+ info = list(pipshow.search_packages_info([ydl_module_name]))
+ if len(info) < 1:
+ return ""
+ info = info[0]
+ url = getattr(info, "home-page", None) or getattr(info, "homepage", None)
+ if not url:
+ urls = getattr(info, "project_urls", None)
+ if urls:
+ urls = {v.split(",")[0].strip(): v.split(",")[1].strip() for v in urls if "," in v}
+ url = urls.get("Homepage") or urls.get("Documentation") or urls.get("Repository")
+ return url
+
+
+def read_proc_stdout(proc, strio):
+ strio.write(proc.stdout.read1().decode())
+
+
+class YdlHandler:
+ def import_ydl_module(self):
+ ydl_module = None
+ if os.environ.get("YOUTUBE_DL").replace("-", "_") in YDL_MODULES:
+ ydl_module = importlib.import_module(
+ os.environ.get("YOUTUBE_DL").replace("-", "_")
+ )
+ else:
+ for module in YDL_MODULES:
+ try:
+ ydl_module = importlib.import_module(module)
+ break
+ except ImportError:
+ pass
+ if ydl_module is None:
+ raise ImportError("No youtube_dl implementation found")
+
+ self.ydl_module_name = ydl_module.__name__.replace("_", "-")
+ self.ydl_website = get_ydl_website(self.ydl_module_name)
+
+ self.ydls_version = os.environ.get("YDLS_VERSION", "")
+ self.ydls_release_date = os.environ.get("YDLS_RELEASE_DATE", "")
+
+ importlib.reload(ydl_module.version)
+ importlib.reload(ydl_module.extractor)
+
+ self.ydl_version = ydl_module.version.__version__
+ self.ydl_extractors = [
+ ie.IE_NAME
+ for ie in ydl_module.extractor.list_extractors(
+ self.app_config["ydl_options"].get("age-limit")
+ )
+ if ie._WORKING
+ ]
+
+ def __init__(self, app_config, jobshandler):
+ self.queue = Queue()
+ self.threads = []
+ self.done = False
+ self.ydl_module_name = None
+ self.ydl_version = None
+ self.ydl_extractors = []
+ self.app_config = app_config
+ self.jobshandler = jobshandler
+
+ self.app_config["ydl_last_update"] = datetime.now()
+
+ self.import_ydl_module()
+
+ print("Using {} module".format(self.ydl_module_name))
+
+ def start(self):
+ self.download_workers_count = self.app_config["ydl_server"].get(
+ "download_workers_count", 2
+ )
+ for i in range(self.download_workers_count):
+ thread = Thread(target=self.worker, args=(i,))
+ self.threads.append(thread)
+ thread.start()
+ print("Started dl worker %i" % i)
+
+ def put(self, obj):
+ self.queue.put(obj)
+
+ def finish(self):
+ self.done = True
+
+ def worker(self, thread_id):
+ db = JobsDB(readonly=True)
+ while not self.done:
+ try:
+ job = self.queue.get(timeout=1)
+ except Empty:
+ continue
+ job_detail = db.get_job_by_id(job.id)
+ if not job_detail or job_detail["status"] == "Aborted":
+ self.queue.task_done()
+ continue
+ job.status = Job.RUNNING
+ self.jobshandler.put((Actions.SET_STATUS, (job.id, job.status)))
+ self.queue.task_done()
+ if job.type == JobType.YDL_DOWNLOAD:
+ output = io.StringIO()
+ try:
+ self.download(job, {"format": job.format}, output)
+ except Exception as e:
+ job.status = Job.FAILED
+ job.log = "Error during download task:\n{}:\n\t{}".format(
+ type(e).__name__, str(e)
+ )
+ print(
+ "Error during download task:\n{}:\n\t{}".format(
+ type(e).__name__, str(e)
+ )
+ )
+ self.jobshandler.put((Actions.UPDATE, job))
+
+ def get_format_and_profile(self, format_string):
+ fmt, audio, profile = None, None, None
+ for s in format_string.split(","):
+ if s.startswith("profile/"):
+ profile = s
+ elif s.startswith("audio/") or s.startswith("bestaudio/"):
+ audio = s
+ else:
+ fmt = s
+ return fmt, audio, profile
+
+ def get_profile(self, profile_str):
+ if not profile_str:
+ return {}
+ profile_name = "/".join(profile_str.split("/")[1:])
+ profile = self.app_config.get("profiles", {}).get(profile_name, {}).get('ydl_options')
+ if not profile:
+ raise Exception("Unknown profile ", profile_str)
+ return profile
+
+ def get_ydl_options(self, ydl_config, request_options):
+ ydl_config = ydl_config.copy()
+ req_format, req_audio, req_profile = self.get_format_and_profile(request_options.get("format"))
+
+ profile = self.get_profile(req_profile)
+ if profile:
+ req_format = profile.get("format") if req_format is None else req_format
+
+ if req_audio is not None and req_format is None:
+ ydl_config.update({"extract-audio": None})
+ ydl_config.update({"audio-format": req_audio.split("/")[-1]})
+
+ if req_format is not None:
+ if req_format == "video/best":
+ req_format = "video/bestvideo"
+ if req_format.startswith("video/"):
+ # youtube-dl downloads BEST video and audio by default
+ if req_format != "video/best":
+ req_format = req_format.split("/")[-1]
+ if req_audio is not None:
+ req_format = req_format + "+" + req_audio.split("/")[-1]
+ else:
+ req_format = req_format + "+bestaudio/best"
+ ydl_config.update({"format": req_format})
+
+ if req_format is None and req_audio is None:
+ ydl_config.update({"format": "video/best"})
+
+ if profile:
+ profile = {k: v for k, v in profile.items() if k != "format"}
+ ydl_config.update(profile)
+ return ydl_config
+
+ def download_log_update(self, job, proc, strio):
+ while job.status == Job.RUNNING:
+ read_proc_stdout(proc, strio)
+ job.log = Job.clean_logs(strio.getvalue())
+ self.jobshandler.put((Actions.SET_LOG, (job.id, job.log)))
+ sleep(3)
+
+ def fetch_metadata(self, url):
+ ydl_opts = self.app_config.get("ydl_options", {})
+ cmd = self.get_ydl_full_cmd(ydl_opts, url, ["-J", "--flat-playlist"])
+
+ proc = Popen(cmd, stdout=PIPE, stderr=PIPE)
+ stdout, stderr = proc.communicate()
+ if proc.wait() != 0:
+ return -1, stderr.decode()
+
+ return 0, [json.loads(s) for s in stdout.decode().strip().split("\n")]
+
+ def get_ydl_full_cmd(self, opt_dict, url, extra_opts=None):
+ cmd = [self.ydl_module_name]
+ if opt_dict is not None:
+ for key, val in opt_dict.items():
+ if isinstance(val, bool) and not val:
+ continue
+ cmd.append("--{}".format(key))
+ if val is not None and not isinstance(val, bool):
+ cmd.append(str(val))
+ if extra_opts is not None and isinstance(extra_opts, list):
+ cmd.extend(extra_opts)
+ cmd.append("--")
+ cmd.extend(url)
+ return cmd
+
+ def download(self, job, request_options, output):
+ ydl_opts = self.get_ydl_options(
+ self.app_config.get("ydl_options", {}), request_options
+ )
+ cmd = self.get_ydl_full_cmd(ydl_opts, job.url)
+
+ rc, metadata = self.fetch_metadata(job.url)
+ if rc != 0:
+ job.log = Job.clean_logs(metadata)
+ job.status = Job.FAILED
+ print("Error in metadata fetching process:\n" + job.log)
+ raise Exception(job.log)
+
+ title = ", ".join(
+ [md.get("title", job.url[i]) for i, md in enumerate(metadata)]
+ )
+ self.jobshandler.put((Actions.SET_NAME, (job.id, title)))
+
+ if metadata[0].get("_type") == "playlist" or len(metadata) > 1:
+ ydl_opts.update(
+ {
+ "output": self.app_config["ydl_server"].get(
+ "output_playlist", ydl_opts.get("output")
+ )
+ }
+ )
+
+ cmd = self.get_ydl_full_cmd(ydl_opts, job.url)
+
+ proc = Popen(cmd, stdout=PIPE, stderr=STDOUT)
+ self.jobshandler.put((Actions.SET_PID, (job.id, proc.pid)))
+ stdout_thread = Thread(
+ target=self.download_log_update, args=(job, proc, output)
+ )
+ stdout_thread.start()
+
+ rc = proc.wait()
+ if rc == 0:
+ read_proc_stdout(proc, output)
+ job.log = Job.clean_logs(output.getvalue())
+ job.status = Job.COMPLETED
+ else:
+ read_proc_stdout(proc, output)
+ job.log = Job.clean_logs(output.getvalue())
+ job.status = Job.FAILED
+ print(
+ "Error in download process (RC=" + str(rc) + "):\n" + output.getvalue()
+ )
+ stdout_thread.join()
+
+ def resume_pending(self):
+ db = JobsDB(readonly=False)
+ jobs = db.get_jobs_with_logs(self.app_config["ydl_server"].get("max_log_entries", 100))
+ not_endeds = [
+ job
+ for job in jobs
+ if job["status"] == "Pending" or job["status"] == "Running"
+ ]
+ for pending in not_endeds:
+ job = Job(
+ pending["name"],
+ Job.PENDING,
+ "Queue stopped",
+ int(pending["type"]),
+ pending["format"],
+ pending["urls"],
+ )
+ job.id = pending["id"]
+ self.jobshandler.put((Actions.RESUME, job))
+
+ def join(self):
+ for thread in self.threads:
+ thread.join()
diff --git a/youtube-dl-server-logs.png b/youtube-dl-server-logs.png
new file mode 100644
index 00000000..18cce6be
Binary files /dev/null and b/youtube-dl-server-logs.png differ
diff --git a/youtube-dl-server-profiles.png b/youtube-dl-server-profiles.png
new file mode 100644
index 00000000..cd194eaa
Binary files /dev/null and b/youtube-dl-server-profiles.png differ
diff --git a/youtube-dl-server.png b/youtube-dl-server.png
index 17661394..99633bad 100644
Binary files a/youtube-dl-server.png and b/youtube-dl-server.png differ
diff --git a/youtube-dl-server.py b/youtube-dl-server.py
index 78fd46c9..f63af329 100644
--- a/youtube-dl-server.py
+++ b/youtube-dl-server.py
@@ -1,135 +1,61 @@
from __future__ import unicode_literals
-import json
-import os
-import subprocess
-from queue import Queue
-from bottle import route, run, Bottle, request, static_file
-from threading import Thread
-import youtube_dl
-from pathlib import Path
-from collections import ChainMap
-
-app = Bottle()
-
-
-app_defaults = {
- 'YDL_FORMAT': 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]',
- 'YDL_EXTRACT_AUDIO_FORMAT': None,
- 'YDL_EXTRACT_AUDIO_QUALITY': '192',
- 'YDL_RECODE_VIDEO_FORMAT': None,
- 'YDL_OUTPUT_TEMPLATE': '/youtube-dl/%(title)s [%(id)s].%(ext)s',
- 'YDL_ARCHIVE_FILE': None,
- 'YDL_SERVER_HOST': '0.0.0.0',
- 'YDL_SERVER_PORT': 8080,
-}
-
-
-@app.route('/youtube-dl')
-def dl_queue_list():
- return static_file('index.html', root='./')
-
-
-@app.route('/youtube-dl/static/:filename#.*#')
-def server_static(filename):
- return static_file(filename, root='./static')
-
-
-@app.route('/youtube-dl/q', method='GET')
-def q_size():
- return {"success": True, "size": json.dumps(list(dl_q.queue))}
-
-
-@app.route('/youtube-dl/q', method='POST')
-def q_put():
- url = request.forms.get("url")
- options = {
- 'format': request.forms.get("format")
- }
-
- if not url:
- return {"success": False, "error": "/q called without a 'url' query param"}
-
- dl_q.put((url, options))
- print("Added url " + url + " to the download queue")
- return {"success": True, "url": url, "options": options}
-
-@app.route("/youtube-dl/update", method="GET")
-def update():
- command = ["pip", "install", "--upgrade", "youtube-dl"]
- proc = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
-
- output, error = proc.communicate()
- return {
- "output": output.decode('ascii'),
- "error": error.decode('ascii')
- }
-
-def dl_worker():
- while not done:
- url, options = dl_q.get()
- download(url, options)
- dl_q.task_done()
-
-
-def get_ydl_options(request_options):
- request_vars = {
- 'YDL_EXTRACT_AUDIO_FORMAT': None,
- 'YDL_RECODE_VIDEO_FORMAT': None,
- }
-
- requested_format = request_options.get('format', 'bestvideo')
-
- if requested_format in ['aac', 'flac', 'mp3', 'm4a', 'opus', 'vorbis', 'wav']:
- request_vars['YDL_EXTRACT_AUDIO_FORMAT'] = requested_format
- elif requested_format == 'bestaudio':
- request_vars['YDL_EXTRACT_AUDIO_FORMAT'] = 'best'
- elif requested_format in ['mp4', 'flv', 'webm', 'ogg', 'mkv', 'avi']:
- request_vars['YDL_RECODE_VIDEO_FORMAT'] = requested_format
-
- ydl_vars = ChainMap(request_vars, os.environ, app_defaults)
-
- postprocessors = []
-
- if(ydl_vars['YDL_EXTRACT_AUDIO_FORMAT']):
- postprocessors.append({
- 'key': 'FFmpegExtractAudio',
- 'preferredcodec': ydl_vars['YDL_EXTRACT_AUDIO_FORMAT'],
- 'preferredquality': ydl_vars['YDL_EXTRACT_AUDIO_QUALITY'],
- })
-
- if(ydl_vars['YDL_RECODE_VIDEO_FORMAT']):
- postprocessors.append({
- 'key': 'FFmpegVideoConvertor',
- 'preferedformat': ydl_vars['YDL_RECODE_VIDEO_FORMAT'],
- })
-
- return {
- 'format': ydl_vars['YDL_FORMAT'],
- 'postprocessors': postprocessors,
- 'outtmpl': ydl_vars['YDL_OUTPUT_TEMPLATE'],
- 'download_archive': ydl_vars['YDL_ARCHIVE_FILE']
- }
-
-
-def download(url, request_options):
- with youtube_dl.YoutubeDL(get_ydl_options(request_options)) as ydl:
- ydl.download([url])
-
-
-dl_q = Queue()
-done = False
-dl_thread = Thread(target=dl_worker)
-dl_thread.start()
-
-print("Updating youtube-dl to the newest version")
-updateResult = update()
-print(updateResult["output"])
-print(updateResult["error"])
-
-print("Started download thread")
-
-app_vars = ChainMap(os.environ, app_defaults)
-
-app.run(host=app_vars['YDL_SERVER_HOST'], port=app_vars['YDL_SERVER_PORT'], debug=True)
-done = True
-dl_thread.join()
+from starlette.applications import Starlette
+from starlette.middleware import Middleware
+from starlette.middleware.cors import CORSMiddleware
+import uvicorn
+import signal
+
+from ydl_server.db import JobsDB
+
+from ydl_server.ydlhandler import YdlHandler
+from ydl_server.jobshandler import JobsHandler
+from ydl_server.config import app_config
+
+from ydl_server.routes import routes
+
+if __name__ == "__main__":
+ JobsDB.init()
+
+ middleware = [Middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"])]
+
+ app = Starlette(
+ routes=routes,
+ debug=app_config["ydl_server"].get("debug", False),
+ middleware=middleware,
+ )
+
+ app.state.running = True
+ app.state.jobshandler = JobsHandler(app_config)
+ app.state.ydlhandler = YdlHandler(app_config, app.state.jobshandler)
+
+ def shutdown():
+ if not app.state.running:
+ return
+ print("Shutting down...")
+ app.state.jobshandler.finish()
+ app.state.ydlhandler.finish()
+ print("Waiting for workers to wrap up...")
+ app.state.ydlhandler.join()
+ app.state.jobshandler.join()
+ print("Shutdown complete.")
+ app.state.running = False
+
+ signal.signal(signal.SIGINT, lambda sig, frame: shutdown())
+ signal.signal(signal.SIGTERM, lambda sig, frame: shutdown())
+
+ app.state.ydlhandler.start()
+ print("Started download threads")
+ app.state.jobshandler.start(app.state.ydlhandler.queue)
+ print("Started jobs manager thread")
+
+ app.state.ydlhandler.resume_pending()
+
+ uvicorn.run(
+ app,
+ host=app_config["ydl_server"].get("host"),
+ port=app_config["ydl_server"].get("port"),
+ log_level=("debug" if app_config["ydl_server"].get("debug", False) else "info"),
+ forwarded_allow_ips=app_config["ydl_server"].get("forwarded_allow_ips", None),
+ proxy_headers=app_config["ydl_server"].get("proxy_headers", True),
+ )
+ shutdown()