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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + + 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()