diff --git a/.github/workflows/update-yt-dlp.yml b/.github/workflows/update-yt-dlp.yml index d5802956..2a145751 100644 --- a/.github/workflows/update-yt-dlp.yml +++ b/.github/workflows/update-yt-dlp.yml @@ -23,26 +23,38 @@ jobs: uses: actions/checkout@v4 with: ref: "dev" + - name: Set up Python uses: actions/setup-python@v4 with: python-version: "3.11" - - name: Update yt-dlp + + - name: Install uv + run: pip install uv + + - name: Sync dependencies + run: uv venv && . .venv/bin/activate && uv sync + + - name: Check and update yt-dlp id: ytdlp_update run: | - pip install pipenv - pipenv sync - VER=`pipenv run pip list -o | awk '$1 == "yt-dlp" {print $3}'` + . .venv/bin/activate + VER=$(uv pip list --outdated | awk '$1 == "yt-dlp" {print $3}') if [ -n "$VER" ]; then echo "YTLDLP_VER=${VER}" >> "$GITHUB_OUTPUT" - pipenv update yt-dlp + uv pip install --upgrade yt-dlp + uv sync --upgrade + UPDATED=true + else + UPDATED=false fi + echo "UPDATED=${UPDATED}" >> "$GITHUB_OUTPUT" + - name: Create Pull Request + if: steps.ytdlp_update.outputs.UPDATED == 'true' uses: peter-evans/create-pull-request@v5 with: title: "[yt-dlp] automated update to ${{ steps.ytdlp_update.outputs.YTLDLP_VER }}" commit-message: "Update yt-dlp to ${{ steps.ytdlp_update.outputs.YTLDLP_VER }}" body: "This is an automated request to update yt-dlp dependency to ${{ steps.ytdlp_update.outputs.YTLDLP_VER }}" delete-branch: true - - diff --git a/.gitignore b/.gitignore index d01e6df6..c107c809 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,5 @@ var/* __pycache__ .venv .idea +dist +build diff --git a/.vscode/settings.json b/.vscode/settings.json index b5623dc6..2d265457 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -15,15 +15,19 @@ "ahas", "ahash", "aiocron", + "anyio", "arrowless", "attl", "autonumber", + "brotlicffi", "consoletitle", "cookiesfrombrowser", "copyts", "cronsim", + "datas", "daterange", "dotenv", + "edgechromium", "euuo", "finaldir", "flac", @@ -32,12 +36,17 @@ "fribidi", "getpid", "gpac", + "hiddenimports", + "hookspath", "httpx", "levelno", "libcurl", + "libjavascriptcoregtk", + "libwebkit", "libx", "matroska", "mbed", + "mccabe", "Microformat", "microformats", "mkvtoolsnix", @@ -46,14 +55,24 @@ "muxdelay", "nodesc", "noprogress", + "onefile", + "pathex", + "platformdirs", "plexmatch", "postprocessor", "preferredcodec", "preferredquality", "printtraffic", + "pycryptodome", + "pyinstaller", + "pypi", + "pyside", + "pywebview", + "qtpy", "quicktime", "rtime", "smhd", + "socketio", "timespec", "tmpfilename", "upgrader", diff --git a/API.md b/API.md index 37b5dc0d..d9c714d7 100644 --- a/API.md +++ b/API.md @@ -26,10 +26,6 @@ This document describes the available endpoints and their usage. All endpoints r - [GET /api/history](#get-apihistory) - [GET /api/tasks](#get-apitasks) - [PUT /api/tasks](#put-apitasks) - - [GET /api/workers](#get-apiworkers) - - [POST /api/workers](#post-apiworkers) - - [PATCH /api/workers/{id}](#patch-apiworkersid) - - [DELETE /api/workers/{id}](#delete-apiworkersid) - [GET /api/player/playlist/{file:.\*}.m3u8](#get-apiplayerplaylistfilem3u8) - [GET /api/player/m3u8/{mode}/{file:.\*}.m3u8](#get-apiplayerm3u8modefilem3u8) - [GET /api/player/segments/{segment}/{file:.\*}.ts](#get-apiplayersegmentssegmentfilets) @@ -391,86 +387,6 @@ or on error --- -### GET /api/workers -**Purpose**: Returns the status of the worker pool and all workers. - -**Response**: -```json -{ - "open": true|false, - "count": 4, - "workers": [ - { - "id": "worker-1", - "data": { "status": "downloading", ... } - }, - { - "id": "worker-2", - "data": { "status": "Waiting for download." } - }, - ... - ] -} -``` -- `open`: Indicates if there are any available workers. -- `count`: Total number of available workers. - ---- - -### POST /api/workers -**Purpose**: Restart the entire worker pool. - -**Response**: -```json -{ - "message": "Workers pool being restarted." -} -``` - ---- - -### PATCH /api/workers/{id} -**Purpose**: Restart a single worker by ID. - -**Path Parameter**: -- `id` = The worker ID. - -**Response**: -```json -{ - "status": "restarted" -} -``` -or -```json -{ - "status": "in_error_state" -} -``` - ---- - -### DELETE /api/workers/{id} -**Purpose**: Stop a single worker by ID. - -**Path Parameter**: -- `id` = The worker ID. - -**Response**: -```json -{ - "status": "stopped" -} -``` -or -```json -{ - "status": "in_error_state" -} -``` - ---- - ### GET /api/player/playlist/{file:.*}.m3u8 **Purpose**: Generate a playlist for a given local media file. diff --git a/Dockerfile b/Dockerfile index 7fe41515..c90cc940 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,16 +13,16 @@ ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONFAULTHANDLER=1 ENV PIP_NO_CACHE_DIR=off ENV PIP_CACHE_DIR=/root/.cache/pip +ENV UV_CACHE_DIR=/root/.cache/pip -# Use sed to strip carriage-return characters from the entrypoint script (in case building on Windows) # Install dependencies -RUN apk add --update coreutils curl gcc g++ musl-dev libffi-dev openssl-dev curl make && pip install pipenv +RUN apk add --update coreutils curl gcc g++ musl-dev libffi-dev openssl-dev curl make && pip install uv -WORKDIR /app +WORKDIR /opt/ -ARG PIPENV_FLAGS="--deploy" -COPY ./Pipfile* . -RUN --mount=type=cache,target=/root/.cache/pip PIPENV_VENV_IN_PROJECT=1 pipenv install ${PIPENV_FLAGS} +COPY ./pyproject.toml ./uv.lock ./ +RUN --mount=type=cache,target=/root/.cache/pip uv venv --system-site-packages --relocatable ./python && \ + VIRTUAL_ENV=/opt/python uv sync --link-mode=copy --active FROM python:3.13-alpine @@ -51,16 +51,13 @@ RUN sed -i 's/\r$//g' /entrypoint.sh && chmod +x /entrypoint.sh COPY --chown=app:app ./app /app/app COPY --chown=app:app --from=node_builder /app/exported /app/ui/exported -COPY --chown=app:app --from=python_builder /app/.venv /opt/python +COPY --chown=app:app --from=python_builder /opt/python /opt/python COPY --chown=app:app --from=ghcr.io/arabcoders/alpine-mp4box /usr/bin/mp4box /usr/bin/mp4box COPY --chown=app:app ./healthcheck.sh /usr/local/bin/healthcheck ENV PATH="/opt/python/bin:$PATH" -RUN chown -R app:app /config /downloads && chmod +x /usr/local/bin/healthcheck && \ - sed -i 's$#!\/app\/\.venv\/bin\/python$#!/opt/python/bin/python$' /opt/python/bin/* && \ - sed -i "s%'\/app\/\.venv'%'/opt/python'%" /opt/python/bin/activate* && \ - chmod +x /usr/bin/mp4box +RUN chown -R app:app /config /downloads && chmod +x /usr/local/bin/healthcheck /usr/bin/mp4box VOLUME /config VOLUME /downloads diff --git a/Pipfile b/Pipfile deleted file mode 100644 index eb4d274f..00000000 --- a/Pipfile +++ /dev/null @@ -1,31 +0,0 @@ -[[source]] -url = "https://pypi.org/simple" -verify_ssl = true -name = "pypi" - -[packages] -python-socketio = ">=5.11.1" -aiohttp = ">=3.9.3" -caribou = ">=0.3.0" -coloredlogs = ">=15.0.1" -aiocron = ">=1.8" -python-dotenv = ">=1.0.1" -python-magic = ">=0.4.27" -debugpy = ">=1.8.1" -httpx = "*" -async-timeout = "*" -pyjson5 = "*" -curl_cffi = "0.7.1" -pysubs2 = "*" -regex = "*" -mutagen = "*" -brotli = "*" -brotlicffi = "*" -anyio = "*" -pycryptodome = "*" -yt-dlp = "*" - -[dev-packages] - -[requires] -python_version = "*" diff --git a/Pipfile.lock b/Pipfile.lock deleted file mode 100644 index 002edd33..00000000 --- a/Pipfile.lock +++ /dev/null @@ -1,1374 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "48fcd9e2b5944d90ebb226668ac120414836e416bea2685411b1c0184f29d7df" - }, - "pipfile-spec": 6, - "requires": { - "python_version": "*" - }, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "aiocron": { - "hashes": [ - "sha256:1bb65a36aee137e8833592783956e0c7dc478bc3e9273fc2841d5d0c6045e4d2", - "sha256:b2612b67c552ebc4d24f524fe0316dec30b44f3c5a1d9a3697493d840aa7a5de" - ], - "index": "pypi", - "markers": "python_version >= '3.9'", - "version": "==2.1" - }, - "aiohappyeyeballs": { - "hashes": [ - "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", - "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8" - ], - "markers": "python_version >= '3.9'", - "version": "==2.6.1" - }, - "aiohttp": { - "hashes": [ - "sha256:08bf55b216c779eddb6e41c1841c17d7ddd12776c7d7b36051c0a292a9ca828e", - "sha256:0d6575df942e7991e1450b731ad9a5726a1116668471a07d749bd9b2cb1f30a7", - "sha256:0e1c33ac0f6a396bcefe9c1d52c9d38a051861885a5c102ca5c8298aba0108fa", - "sha256:1496c9e785d0432a4eae6c059f1d68423fb6264cbdacaff2d9ab1859be66c5bb", - "sha256:1e4eebfe470e22cc4b374d7e32c07e96d777a5c0fa51f3824de68e697da037ec", - "sha256:2804245093897b77736540f75826d35587819e143f0f95e951bfea8eb312bf09", - "sha256:29ff7876ff7e4a8029642334a81891cb5a842f1e405195c2946f697102756670", - "sha256:2be095a420a9f9a12eff343d877ae180dd919238b539431af08cef929e874759", - "sha256:2c7c848ad08722bfc9da0b9fe5f44cde4fa6499d34ece11462c5b7b1f75a5a1b", - "sha256:3091b4883f405dbabeb9ea821a25dec16d03a51c3e0d2752fc3ab48b652bf196", - "sha256:362832e0b7c46c7ad3cf2f693061e17f1198f8d7fa4e907c304b3208241161c8", - "sha256:372f2237cade45f563d973c2a913895f2699a892c0eb11c55c6880b6f0acf219", - "sha256:388b5947aa6931ef4ce3ed4edde6853e84980677886992cfadcf733dd06eed63", - "sha256:38dc536059cc0624e22273905a1df74b231ac903d73af59ee6e6e3139f05a28b", - "sha256:3a5938973105cd5ff17176e8cb36bc19cac7c82ae7c58c0dbd7e023972d0c708", - "sha256:3c9f52149d8249566e72c50c7985c2345521b3b78f84aa86f6f492cd50b14793", - "sha256:410e96cc6824fc4ced9703fb2ac2d06c6190d21fc6f5b588f62b1918628449c1", - "sha256:41f686749a099b507563a5c0cb4fd77367b05448a2c1758784ad506a28e9e579", - "sha256:43e93987fe9df4349db8deae7c391695538c35e4ba893133c7e823234f6e4537", - "sha256:4486f399573c94b223411bc5686b5cdc661f4dd67daece800662356e46b3a2b5", - "sha256:4a46fe4a4c66b2712059e48a8384eb93565fbe3251af4844860fed846ef4ca75", - "sha256:4acec2b5de65adc469837260be8408d5f53d4c8ae60631be868e9d7eb8563167", - "sha256:4ccd1e07b61c26532f1a7908430c30d687425bbf2d4da26f09bc1f2acf06a5f9", - "sha256:4e80ef94a0993c7124b69bf1a95b5d26f22f24e5fdc6af22ca143105edde22ed", - "sha256:519f5454b6018158ae0e789b8f6a88726c47dd680982eb318ef3ca4dee727314", - "sha256:53ae6140303ab04a7203f8fcb9ca5b2c5abea46e12e8d6f65575d0f197812e22", - "sha256:56d0f622b3595f3aeaefd07aca9d425748fc8bf5e9e502f75a2dad15f2b875b2", - "sha256:5a1a280e27b2c772a9d69dfd0744929f8628a6b8b6e6e87c0125c8c417501a21", - "sha256:5e7741c53d473204f89dd26f3b087a5883c742add8d6504d0d7d3ad3ff1cd1b7", - "sha256:6055f53c70938498884e71ca966abe8e9e7558489e13a7e40b6384dee7230d1d", - "sha256:6357abdc7a2cfb113274c4f4a7f086bdca36905924953bf7a9e3f6add3aec7c5", - "sha256:6600550593c440ef29ca2a14b8a52ac91b9f494d85f75294409ec6ad5637476f", - "sha256:66605ac59c9fbcd4159b0c0cfa239173ab77abc18cf714a1d0569cbabe3c836d", - "sha256:67759acb11673c1b976a516f2d69a73433aad70ed04e44ce79eaf0e58219535e", - "sha256:6ea0db720f2996f9b799c8ba6fbdd12063add509a81a398cd31a3fb152efae0d", - "sha256:6f04af3bf040dc8fd9b2bc0e465f5aca6fc5349fa08bd7f08142974a2ded21bf", - "sha256:74ff39445f94923cf595e9e6dd602ecbe66b12364e2207e61342b8834417f8da", - "sha256:76392cbadc1ccc0a8c02098b74c0240d53c644b10a81e1addbc1666dce3cd62a", - "sha256:777663011746b37b5df13df7826cb28ebc447b21ac8aa8278b7825404897dd5f", - "sha256:77cb9dba16486ecfeac8076763600b9714941e0ff696e53a30e8d408d9a196ca", - "sha256:7a3691583470d4397aca70fbf8e0f0778b63a2c2a6a23263bdeeb68395972f29", - "sha256:7cd6e299292ba085a3642cb4085b393f45bbca45c067182d15e33c2e3473283c", - "sha256:81a1ca045593149d3366286c30c57ebb63d2f28feca8ca3fae049c22ed8520c4", - "sha256:82a59cf086396a409d6d2350c122aada07f1f56bb529734994d37bcafc8cf101", - "sha256:838a091be15ce619a83896c8485e814215f3383952dd58aec932d0f9ae85a02b", - "sha256:845a67d26ee9578d20738975591dccd0fcae7104c89cc112316787f9fdfe8b61", - "sha256:8493a42d5b2a736c6804239b985feebeea1c60f8fcb46a3607d6dce3c1a42b12", - "sha256:8c19b1de25703560fa64f998dfc3685040b52996056e048b3406c8e97dcfa1e3", - "sha256:8de89889df856101176ccaf570075b73b62ea9d86e11e642d0f20ecd62a34ce8", - "sha256:8eb5d60790ca3563a376ef297dfac3c4c5ec7a7e180b9fe0314f238813fd2ab0", - "sha256:93317649d65cc895ba1fe5384353cb6c44638db39ebb55dabe3dade34a1b1177", - "sha256:94f98e0e5a49f89b252e115844f756c04fc8050f38252a32a3dd994ce8121f10", - "sha256:97fd97abd4cf199eff4041d0346a7dc68b60deab177f01de87283be513ffc3ab", - "sha256:9b6a660163b055686dbb0acc961978fd14537eba5d9da6cbdb4dced7a8d3be1a", - "sha256:9b9345918f5b5156a5712c37d1d331baf320df67547ea032a49a609b773c3606", - "sha256:9ca179427f7cbd3476eca3bfc229087c112b0418242c5b56f9f0f9c0e681b906", - "sha256:9ed5af1cce257cca27a3e920b003b3b397f63418a203064b7d804ea3b45782af", - "sha256:a4ee037aec7ccc8777b0f9603085a2c53108368443624f7dc834028b16591541", - "sha256:a7b3b9cbe83e3918a1918b0de274884f17b64224c1c9210a6fb0f7c10d246636", - "sha256:ad01793164661af70918490ef8efc2c09df7a3c686b6c84ca90a2d69cdbc3911", - "sha256:adbb2046600a60e37a54ea9b77b0ddef280029b0a853624a8e9b2b71a037c890", - "sha256:b058cf2ba6adba699960d7bc403411c8a99ab5d3e5ea3eb01473638ae7d1a30e", - "sha256:b19763f88f058e9c605f79cde8a800660f7e259162b80982111cc631dfc54bf0", - "sha256:b1f532d312a42397e6f591499acf707cece6462f745c5670bb7c70d1bb963882", - "sha256:b4aed5233a9d13e34e8624ecb798533aa2da97e7048cc69671b7a6d7a2efe7e8", - "sha256:b56a4fb31fe82ee58cd8cc157e4fc58d19fba2580b46a62fe7808353bb9b82df", - "sha256:b780b402e6361c4cfcec252580f5ecdd86cb68376520ac34748d3f8b262dd598", - "sha256:bbdb60ab46f696a5e52d98a830b11c034d601bbe2496a82a19d94268257ac63b", - "sha256:bca9329faa73c42061a67b8b53e6b1d46b73e3411636bfe1d07c58d81067b902", - "sha256:c4e7155fbdf89084abde1b33f05d681d8ffa0d5d07698d5d76a03ebdeb062848", - "sha256:c7b83c829be3cddaf958dee8108e09b1502c215e95064d3045015298dbded54a", - "sha256:c8d9b576aa4e1359fcc479532b8a21803840fd61013eec875746b29c3930f073", - "sha256:cb3f3dcb59f3e16819a1c7d3fa32e7b87255b661c1e139a1b5940bde270704ab", - "sha256:cf981bbfb7ff2ebc1b3bfae49d2efe2c51ca1cf3d90867f47c310df65398e85e", - "sha256:d741923905f267ad5d5c8f86a56f9d2beac9f32a36c217c5d9ef65cd74fd8ca0", - "sha256:d909d0b217e85f366bfff45298966ea0dc49d76666fef2eb5777adc5b7aaa292", - "sha256:e2e1f6e7825d3830ee85ddf5d322300d15053e94c66ff8b3d5e8ef0f152c0f1a", - "sha256:e506ae5c4c05d1a1e87edd64b994cea2d49385d41d32e1c6be8764f31cf2245c", - "sha256:e85c6833be3f49cead2e7bc79080e5c18d6dab9af32226ab5a01dc20c523e7d9", - "sha256:ed109a3eef13620c8ce57c429119990be08782c346465c265a23052e41e2cf42", - "sha256:f1a478d055c77fa549251d8b2a8a850918edbbf9941245ef6edbbb65d924edd7", - "sha256:f466ae8f9c02993b7d167be685bdbeb527cf254a3cfcc757697e0e336399d0a2", - "sha256:f85e48970aff5b00af94a5f6311ee0b61eca8bbc8769df39873fc68d747ca609", - "sha256:f8fa7c8ee01b54367cafb7e82947e36e57f9cb243d7c4d66e03fb96661b082ae", - "sha256:fc8086515dd1016b67db9ccebb7159234226dba99fb6a895a0c9270b644cf525", - "sha256:ffa9928fd37061c8e35b85d3f1b4a256d0c3e8cbd421c1d8bd0ab45461b6a838" - ], - "index": "pypi", - "markers": "python_version >= '3.9'", - "version": "==3.12.7" - }, - "aiosignal": { - "hashes": [ - "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5", - "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54" - ], - "markers": "python_version >= '3.9'", - "version": "==1.3.2" - }, - "anyio": { - "hashes": [ - "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", - "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c" - ], - "index": "pypi", - "markers": "python_version >= '3.9'", - "version": "==4.9.0" - }, - "async-timeout": { - "hashes": [ - "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", - "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3" - ], - "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==5.0.1" - }, - "attrs": { - "hashes": [ - "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", - "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b" - ], - "markers": "python_version >= '3.8'", - "version": "==25.3.0" - }, - "bidict": { - "hashes": [ - "sha256:03069d763bc387bbd20e7d49914e75fc4132a41937fa3405417e1a5a2d006d71", - "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5" - ], - "markers": "python_version >= '3.8'", - "version": "==0.23.1" - }, - "brotli": { - "hashes": [ - "sha256:03d20af184290887bdea3f0f78c4f737d126c74dc2f3ccadf07e54ceca3bf208", - "sha256:0541e747cce78e24ea12d69176f6a7ddb690e62c425e01d31cc065e69ce55b48", - "sha256:069a121ac97412d1fe506da790b3e69f52254b9df4eb665cd42460c837193354", - "sha256:0737ddb3068957cf1b054899b0883830bb1fec522ec76b1098f9b6e0f02d9419", - "sha256:0b63b949ff929fbc2d6d3ce0e924c9b93c9785d877a21a1b678877ffbbc4423a", - "sha256:0c6244521dda65ea562d5a69b9a26120769b7a9fb3db2fe9545935ed6735b128", - "sha256:11d00ed0a83fa22d29bc6b64ef636c4552ebafcef57154b4ddd132f5638fbd1c", - "sha256:141bd4d93984070e097521ed07e2575b46f817d08f9fa42b16b9b5f27b5ac088", - "sha256:19c116e796420b0cee3da1ccec3b764ed2952ccfcc298b55a10e5610ad7885f9", - "sha256:1ab4fbee0b2d9098c74f3057b2bc055a8bd92ccf02f65944a241b4349229185a", - "sha256:1ae56aca0402a0f9a3431cddda62ad71666ca9d4dc3a10a142b9dce2e3c0cda3", - "sha256:1b2c248cd517c222d89e74669a4adfa5577e06ab68771a529060cf5a156e9757", - "sha256:1e9a65b5736232e7a7f91ff3d02277f11d339bf34099a56cdab6a8b3410a02b2", - "sha256:224e57f6eac61cc449f498cc5f0e1725ba2071a3d4f48d5d9dffba42db196438", - "sha256:22fc2a8549ffe699bfba2256ab2ed0421a7b8fadff114a3d201794e45a9ff578", - "sha256:23032ae55523cc7bccb4f6a0bf368cd25ad9bcdcc1990b64a647e7bbcce9cb5b", - "sha256:2333e30a5e00fe0fe55903c8832e08ee9c3b1382aacf4db26664a16528d51b4b", - "sha256:2954c1c23f81c2eaf0b0717d9380bd348578a94161a65b3a2afc62c86467dd68", - "sha256:2a24c50840d89ded6c9a8fdc7b6ed3692ed4e86f1c4a4a938e1e92def92933e0", - "sha256:2de9d02f5bda03d27ede52e8cfe7b865b066fa49258cbab568720aa5be80a47d", - "sha256:2feb1d960f760a575dbc5ab3b1c00504b24caaf6986e2dc2b01c09c87866a943", - "sha256:30924eb4c57903d5a7526b08ef4a584acc22ab1ffa085faceb521521d2de32dd", - "sha256:316cc9b17edf613ac76b1f1f305d2a748f1b976b033b049a6ecdfd5612c70409", - "sha256:32d95b80260d79926f5fab3c41701dbb818fde1c9da590e77e571eefd14abe28", - "sha256:38025d9f30cf4634f8309c6874ef871b841eb3c347e90b0851f63d1ded5212da", - "sha256:39da8adedf6942d76dc3e46653e52df937a3c4d6d18fdc94a7c29d263b1f5b50", - "sha256:3c0ef38c7a7014ffac184db9e04debe495d317cc9c6fb10071f7fefd93100a4f", - "sha256:3d7954194c36e304e1523f55d7042c59dc53ec20dd4e9ea9d151f1b62b4415c0", - "sha256:3ee8a80d67a4334482d9712b8e83ca6b1d9bc7e351931252ebef5d8f7335a547", - "sha256:4093c631e96fdd49e0377a9c167bfd75b6d0bad2ace734c6eb20b348bc3ea180", - "sha256:43395e90523f9c23a3d5bdf004733246fba087f2948f87ab28015f12359ca6a0", - "sha256:43ce1b9935bfa1ede40028054d7f48b5469cd02733a365eec8a329ffd342915d", - "sha256:4410f84b33374409552ac9b6903507cdb31cd30d2501fc5ca13d18f73548444a", - "sha256:494994f807ba0b92092a163a0a283961369a65f6cbe01e8891132b7a320e61eb", - "sha256:4d4a848d1837973bf0f4b5e54e3bec977d99be36a7895c61abb659301b02c112", - "sha256:4ed11165dd45ce798d99a136808a794a748d5dc38511303239d4e2363c0695dc", - "sha256:4f3607b129417e111e30637af1b56f24f7a49e64763253bbc275c75fa887d4b2", - "sha256:510b5b1bfbe20e1a7b3baf5fed9e9451873559a976c1a78eebaa3b86c57b4265", - "sha256:524f35912131cc2cabb00edfd8d573b07f2d9f21fa824bd3fb19725a9cf06327", - "sha256:587ca6d3cef6e4e868102672d3bd9dc9698c309ba56d41c2b9c85bbb903cdb95", - "sha256:58d4b711689366d4a03ac7957ab8c28890415e267f9b6589969e74b6e42225ec", - "sha256:5b3cc074004d968722f51e550b41a27be656ec48f8afaeeb45ebf65b561481dd", - "sha256:5dab0844f2cf82be357a0eb11a9087f70c5430b2c241493fc122bb6f2bb0917c", - "sha256:5e55da2c8724191e5b557f8e18943b1b4839b8efc3ef60d65985bcf6f587dd38", - "sha256:5eeb539606f18a0b232d4ba45adccde4125592f3f636a6182b4a8a436548b914", - "sha256:5f4d5ea15c9382135076d2fb28dde923352fe02951e66935a9efaac8f10e81b0", - "sha256:5fb2ce4b8045c78ebbc7b8f3c15062e435d47e7393cc57c25115cfd49883747a", - "sha256:6172447e1b368dcbc458925e5ddaf9113477b0ed542df258d84fa28fc45ceea7", - "sha256:6967ced6730aed543b8673008b5a391c3b1076d834ca438bbd70635c73775368", - "sha256:6974f52a02321b36847cd19d1b8e381bf39939c21efd6ee2fc13a28b0d99348c", - "sha256:6c3020404e0b5eefd7c9485ccf8393cfb75ec38ce75586e046573c9dc29967a0", - "sha256:6c6e0c425f22c1c719c42670d561ad682f7bfeeef918edea971a79ac5252437f", - "sha256:70051525001750221daa10907c77830bc889cb6d865cc0b813d9db7fefc21451", - "sha256:7905193081db9bfa73b1219140b3d315831cbff0d8941f22da695832f0dd188f", - "sha256:7bc37c4d6b87fb1017ea28c9508b36bbcb0c3d18b4260fcdf08b200c74a6aee8", - "sha256:7c4855522edb2e6ae7fdb58e07c3ba9111e7621a8956f481c68d5d979c93032e", - "sha256:7e4c4629ddad63006efa0ef968c8e4751c5868ff0b1c5c40f76524e894c50248", - "sha256:7eedaa5d036d9336c95915035fb57422054014ebdeb6f3b42eac809928e40d0c", - "sha256:7f4bf76817c14aa98cc6697ac02f3972cb8c3da93e9ef16b9c66573a68014f91", - "sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724", - "sha256:832436e59afb93e1836081a20f324cb185836c617659b07b129141a8426973c7", - "sha256:861bf317735688269936f755fa136a99d1ed526883859f86e41a5d43c61d8966", - "sha256:87a3044c3a35055527ac75e419dfa9f4f3667a1e887ee80360589eb8c90aabb9", - "sha256:890b5a14ce214389b2cc36ce82f3093f96f4cc730c1cffdbefff77a7c71f2a97", - "sha256:89f4988c7203739d48c6f806f1e87a1d96e0806d44f0fba61dba81392c9e474d", - "sha256:8bf32b98b75c13ec7cf774164172683d6e7891088f6316e54425fde1efc276d5", - "sha256:8dadd1314583ec0bf2d1379f7008ad627cd6336625d6679cf2f8e67081b83acf", - "sha256:901032ff242d479a0efa956d853d16875d42157f98951c0230f69e69f9c09bac", - "sha256:9011560a466d2eb3f5a6e4929cf4a09be405c64154e12df0dd72713f6500e32b", - "sha256:906bc3a79de8c4ae5b86d3d75a8b77e44404b0f4261714306e3ad248d8ab0951", - "sha256:919e32f147ae93a09fe064d77d5ebf4e35502a8df75c29fb05788528e330fe74", - "sha256:91d7cc2a76b5567591d12c01f019dd7afce6ba8cba6571187e21e2fc418ae648", - "sha256:929811df5462e182b13920da56c6e0284af407d1de637d8e536c5cd00a7daf60", - "sha256:949f3b7c29912693cee0afcf09acd6ebc04c57af949d9bf77d6101ebb61e388c", - "sha256:a090ca607cbb6a34b0391776f0cb48062081f5f60ddcce5d11838e67a01928d1", - "sha256:a1fd8a29719ccce974d523580987b7f8229aeace506952fa9ce1d53a033873c8", - "sha256:a37b8f0391212d29b3a91a799c8e4a2855e0576911cdfb2515487e30e322253d", - "sha256:a3daabb76a78f829cafc365531c972016e4aa8d5b4bf60660ad8ecee19df7ccc", - "sha256:a469274ad18dc0e4d316eefa616d1d0c2ff9da369af19fa6f3daa4f09671fd61", - "sha256:a599669fd7c47233438a56936988a2478685e74854088ef5293802123b5b2460", - "sha256:a743e5a28af5f70f9c080380a5f908d4d21d40e8f0e0c8901604d15cfa9ba751", - "sha256:a77def80806c421b4b0af06f45d65a136e7ac0bdca3c09d9e2ea4e515367c7e9", - "sha256:a7e53012d2853a07a4a79c00643832161a910674a893d296c9f1259859a289d2", - "sha256:a93dde851926f4f2678e704fadeb39e16c35d8baebd5252c9fd94ce8ce68c4a0", - "sha256:aac0411d20e345dc0920bdec5548e438e999ff68d77564d5e9463a7ca9d3e7b1", - "sha256:ae15b066e5ad21366600ebec29a7ccbc86812ed267e4b28e860b8ca16a2bc474", - "sha256:aea440a510e14e818e67bfc4027880e2fb500c2ccb20ab21c7a7c8b5b4703d75", - "sha256:af6fa6817889314555aede9a919612b23739395ce767fe7fcbea9a80bf140fe5", - "sha256:b760c65308ff1e462f65d69c12e4ae085cff3b332d894637f6273a12a482d09f", - "sha256:be36e3d172dc816333f33520154d708a2657ea63762ec16b62ece02ab5e4daf2", - "sha256:c247dd99d39e0338a604f8c2b3bc7061d5c2e9e2ac7ba9cc1be5a69cb6cd832f", - "sha256:c5529b34c1c9d937168297f2c1fde7ebe9ebdd5e121297ff9c043bdb2ae3d6fb", - "sha256:c8146669223164fc87a7e3de9f81e9423c67a79d6b3447994dfb9c95da16e2d6", - "sha256:c8fd5270e906eef71d4a8d19b7c6a43760c6abcfcc10c9101d14eb2357418de9", - "sha256:ca63e1890ede90b2e4454f9a65135a4d387a4585ff8282bb72964fab893f2111", - "sha256:caf9ee9a5775f3111642d33b86237b05808dafcd6268faa492250e9b78046eb2", - "sha256:cb1dac1770878ade83f2ccdf7d25e494f05c9165f5246b46a621cc849341dc01", - "sha256:cdad5b9014d83ca68c25d2e9444e28e967ef16e80f6b436918c700c117a85467", - "sha256:cdbc1fc1bc0bff1cef838eafe581b55bfbffaed4ed0318b724d0b71d4d377619", - "sha256:ceb64bbc6eac5a140ca649003756940f8d6a7c444a68af170b3187623b43bebf", - "sha256:d0c5516f0aed654134a2fc936325cc2e642f8a0e096d075209672eb321cff408", - "sha256:d143fd47fad1db3d7c27a1b1d66162e855b5d50a89666af46e1679c496e8e579", - "sha256:d192f0f30804e55db0d0e0a35d83a9fead0e9a359a9ed0285dbacea60cc10a84", - "sha256:d2b35ca2c7f81d173d2fadc2f4f31e88cc5f7a39ae5b6db5513cf3383b0e0ec7", - "sha256:d342778ef319e1026af243ed0a07c97acf3bad33b9f29e7ae6a1f68fd083e90c", - "sha256:d487f5432bf35b60ed625d7e1b448e2dc855422e87469e3f450aa5552b0eb284", - "sha256:d7702622a8b40c49bffb46e1e3ba2e81268d5c04a34f460978c6b5517a34dd52", - "sha256:db85ecf4e609a48f4b29055f1e144231b90edc90af7481aa731ba2d059226b1b", - "sha256:de6551e370ef19f8de1807d0a9aa2cdfdce2e85ce88b122fe9f6b2b076837e59", - "sha256:e1140c64812cb9b06c922e77f1c26a75ec5e3f0fb2bf92cc8c58720dec276752", - "sha256:e4fe605b917c70283db7dfe5ada75e04561479075761a0b3866c081d035b01c1", - "sha256:e6a904cb26bfefc2f0a6f240bdf5233be78cd2488900a2f846f3c3ac8489ab80", - "sha256:e79e6520141d792237c70bcd7a3b122d00f2613769ae0cb61c52e89fd3443839", - "sha256:e84799f09591700a4154154cab9787452925578841a94321d5ee8fb9a9a328f0", - "sha256:e93dfc1a1165e385cc8239fab7c036fb2cd8093728cbd85097b284d7b99249a2", - "sha256:efa8b278894b14d6da122a72fefcebc28445f2d3f880ac59d46c90f4c13be9a3", - "sha256:f0d8a7a6b5983c2496e364b969f0e526647a06b075d034f3297dc66f3b360c64", - "sha256:f0db75f47be8b8abc8d9e31bc7aad0547ca26f24a54e6fd10231d623f183d089", - "sha256:f296c40e23065d0d6650c4aefe7470d2a25fffda489bcc3eb66083f3ac9f6643", - "sha256:f31859074d57b4639318523d6ffdca586ace54271a73ad23ad021acd807eb14b", - "sha256:f66b5337fa213f1da0d9000bc8dc0cb5b896b726eefd9c6046f699b169c41b9e", - "sha256:f733d788519c7e3e71f0855c96618720f5d3d60c3cb829d8bbb722dddce37985", - "sha256:fce1473f3ccc4187f75b4690cfc922628aed4d3dd013d047f95a9b3919a86596", - "sha256:fd5f17ff8f14003595ab414e45fce13d073e0762394f957182e69035c9f3d7c2", - "sha256:fdc3ff3bfccdc6b9cc7c342c03aa2400683f0cb891d46e94b64a197910dc4064" - ], - "index": "pypi", - "version": "==1.1.0" - }, - "brotlicffi": { - "hashes": [ - "sha256:19ffc919fa4fc6ace69286e0a23b3789b4219058313cf9b45625016bf7ff996b", - "sha256:1a807d760763e398bbf2c6394ae9da5815901aa93ee0a37bca5efe78d4ee3171", - "sha256:1b12b50e07c3911e1efa3a8971543e7648100713d4e0971b13631cce22c587eb", - "sha256:246f1d1a90279bb6069de3de8d75a8856e073b8ff0b09dcca18ccc14cec85979", - "sha256:2a7ae37e5d79c5bdfb5b4b99f2715a6035e6c5bf538c3746abc8e26694f92f33", - "sha256:2e4aeb0bd2540cb91b069dbdd54d458da8c4334ceaf2d25df2f4af576d6766ca", - "sha256:2f3711be9290f0453de8eed5275d93d286abe26b08ab4a35d7452caa1fef532f", - "sha256:37c26ecb14386a44b118ce36e546ce307f4810bc9598a6e6cb4f7fca725ae7e6", - "sha256:391151ec86bb1c683835980f4816272a87eaddc46bb91cbf44f62228b84d8cca", - "sha256:3de0cf28a53a3238b252aca9fed1593e9d36c1d116748013339f0949bfc84112", - "sha256:4b7b0033b0d37bb33009fb2fef73310e432e76f688af76c156b3594389d81391", - "sha256:54a07bb2374a1eba8ebb52b6fafffa2afd3c4df85ddd38fcc0511f2bb387c2a8", - "sha256:6be5ec0e88a4925c91f3dea2bb0013b3a2accda6f77238f76a34a1ea532a1cb0", - "sha256:7901a7dc4b88f1c1475de59ae9be59799db1007b7d059817948d8e4f12e24e35", - "sha256:84763dbdef5dd5c24b75597a77e1b30c66604725707565188ba54bab4f114820", - "sha256:8557a8559509b61e65083f8782329188a250102372576093c88930c875a69838", - "sha256:994a4f0681bb6c6c3b0925530a1926b7a189d878e6e5e38fae8efa47c5d9c613", - "sha256:9b6068e0f3769992d6b622a1cd2e7835eae3cf8d9da123d7f51ca9c1e9c333e5", - "sha256:9b7ae6bd1a3f0df532b6d67ff674099a96d22bc0948955cb338488c31bfb8851", - "sha256:9feb210d932ffe7798ee62e6145d3a757eb6233aa9a4e7db78dd3690d7755814", - "sha256:add0de5b9ad9e9aa293c3aa4e9deb2b61e99ad6c1634e01d01d98c03e6a354cc", - "sha256:b77827a689905143f87915310b93b273ab17888fd43ef350d4832c4a71083c13", - "sha256:ca72968ae4eaf6470498d5c2887073f7efe3b1e7d7ec8be11a06a79cc810e990", - "sha256:cc4bc5d82bc56ebd8b514fb8350cfac4627d6b0743382e46d033976a5f80fab6", - "sha256:ce01c7316aebc7fce59da734286148b1d1b9455f89cf2c8a4dfce7d41db55c2d", - "sha256:d9eb71bb1085d996244439154387266fd23d6ad37161f6f52f1cd41dd95a3808", - "sha256:fa8ca0623b26c94fccc3a1fdd895be1743b838f3917300506d04aa3346fd2a14" - ], - "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==1.1.0.0" - }, - "caribou": { - "hashes": [ - "sha256:13a66fc9ef9b1c9e9ef220876d59a44ee5eff9e579655252271b7514fc0ad787", - "sha256:5c7a036584b34021011f1512620152272924e55ca87c7c805073aeef31c5baed" - ], - "index": "pypi", - "version": "==0.4.1" - }, - "certifi": { - "hashes": [ - "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", - "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3" - ], - "markers": "python_version >= '3.6'", - "version": "==2025.4.26" - }, - "cffi": { - "hashes": [ - "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", - "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", - "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1", - "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", - "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", - "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", - "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", - "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36", - "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", - "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", - "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc", - "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", - "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", - "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", - "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", - "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", - "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", - "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", - "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", - "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b", - "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", - "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", - "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c", - "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", - "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", - "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", - "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8", - "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1", - "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", - "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", - "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", - "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", - "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0", - "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", - "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", - "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", - "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", - "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", - "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", - "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16", - "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", - "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e", - "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", - "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964", - "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", - "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", - "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", - "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3", - "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662", - "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", - "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", - "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", - "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", - "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", - "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", - "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", - "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", - "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9", - "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7", - "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", - "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", - "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", - "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", - "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", - "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", - "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", - "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b" - ], - "markers": "python_version >= '3.8'", - "version": "==1.17.1" - }, - "coloredlogs": { - "hashes": [ - "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", - "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0" - ], - "index": "pypi", - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==15.0.1" - }, - "cronsim": { - "hashes": [ - "sha256:5aab98716ef90ab5ac6be294b2c3965dbf76dc869f048846a0af74ebb506c10d", - "sha256:5e153ff8ed64da7ee8d5caac470dbeda8024ab052c3010b1be149772b4801835" - ], - "markers": "python_version >= '3.8'", - "version": "==2.6" - }, - "curl-cffi": { - "hashes": [ - "sha256:0719fec4b5e1c300bf58411b1cea26cb91c44492fcf5a14ef684fe085f4d8b6e", - "sha256:0eb5b08f562749639529e6990ff1b10a40e53ed45115e15f00b239230eabb927", - "sha256:1e53ab76259b575017d3260854456ba6a3fbe31cee9b44edd275d4ea9f0f20e1", - "sha256:318a9a21f69e720ca904a0edc80bcbb7bbb75a4bab7b31341a202f07d5378c8e", - "sha256:39d04ee1fc5f668ce53234051153031b3a3714300b772379e276565ad7cd244c", - "sha256:54edae42b25f30048fd6c2de06ed9df37bbe6ffdce14cc8a27c79f8c7d47977a", - "sha256:5c347e221ddbbde2275aa7cde00933402638c2062a3984104f66b1bb20528545", - "sha256:8a64b12432146a3f178c4792c91188c18f50cc4b76e908ffc3206442c4610894", - "sha256:99a5cc1d9ca59692cc5c175da0b397104283a0fea7515045fd22a7296296d82b", - "sha256:e60f0dca3a55298898c62c21f0d8461e61aab96d033a7e9cead6160462728f7f" - ], - "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==0.7.1" - }, - "debugpy": { - "hashes": [ - "sha256:0f920c7f9af409d90f5fd26e313e119d908b0dd2952c2393cd3247a462331f15", - "sha256:1b2ac8c13b2645e0b1eaf30e816404990fbdb168e193322be8f545e8c01644a9", - "sha256:281d44d248a0e1791ad0eafdbbd2912ff0de9eec48022a5bfbc332957487ed3f", - "sha256:329a15d0660ee09fec6786acdb6e0443d595f64f5d096fc3e3ccf09a4259033f", - "sha256:3784ec6e8600c66cbdd4ca2726c72d8ca781e94bce2f396cc606d458146f8f4e", - "sha256:3d937d93ae4fa51cdc94d3e865f535f185d5f9748efb41d0d49e33bf3365bd79", - "sha256:413512d35ff52c2fb0fd2d65e69f373ffd24f0ecb1fac514c04a668599c5ce7f", - "sha256:4c9156f7524a0d70b7a7e22b2e311d8ba76a15496fb00730e46dcdeedb9e1eea", - "sha256:5349b7c3735b766a281873fbe32ca9cca343d4cc11ba4a743f84cb854339ff35", - "sha256:5aa56ef8538893e4502a7d79047fe39b1dae08d9ae257074c6464a7b290b806f", - "sha256:5cd9a579d553b6cb9759a7908a41988ee6280b961f24f63336835d9418216a20", - "sha256:684eaf43c95a3ec39a96f1f5195a7ff3d4144e4a18d69bb66beeb1a6de605d6e", - "sha256:7118d462fe9724c887d355eef395fae68bc764fd862cdca94e70dcb9ade8a23d", - "sha256:7816acea4a46d7e4e50ad8d09d963a680ecc814ae31cdef3622eb05ccacf7b01", - "sha256:7cd287184318416850aa8b60ac90105837bb1e59531898c07569d197d2ed5322", - "sha256:8899c17920d089cfa23e6005ad9f22582fd86f144b23acb9feeda59e84405b84", - "sha256:93fee753097e85623cab1c0e6a68c76308cd9f13ffdf44127e6fab4fbf024339", - "sha256:b1528cfee6c1b1c698eb10b6b096c598738a8238822d218173d21c3086de8123", - "sha256:b44985f97cc3dd9d52c42eb59ee9d7ee0c4e7ecd62bca704891f997de4cef23d", - "sha256:c442f20577b38cc7a9aafecffe1094f78f07fb8423c3dddb384e6b8f49fd2987", - "sha256:c99295c76161ad8d507b413cd33422d7c542889fbb73035889420ac1fad354f2", - "sha256:cf431c343a99384ac7eab2f763980724834f933a271e90496944195318c619e2", - "sha256:d235e4fa78af2de4e5609073972700523e372cf5601742449970110d565ca28c", - "sha256:d5582bcbe42917bc6bbe5c12db1bffdf21f6bfc28d4554b738bf08d50dc0c8c3", - "sha256:f117dedda6d969c5c9483e23f573b38f4e39412845c7bc487b6f2648df30fe84", - "sha256:f6bb5c0dcf80ad5dbc7b7d6eac484e2af34bdacdf81df09b6a3e62792b722826" - ], - "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==1.8.14" - }, - "frozenlist": { - "hashes": [ - "sha256:01bcaa305a0fdad12745502bfd16a1c75b14558dabae226852f9159364573117", - "sha256:03572933a1969a6d6ab509d509e5af82ef80d4a5d4e1e9f2e1cdd22c77a3f4d2", - "sha256:0dbae96c225d584f834b8d3cc688825911960f003a85cb0fd20b6e5512468c42", - "sha256:0e6f8653acb82e15e5443dba415fb62a8732b68fe09936bb6d388c725b57f812", - "sha256:0f2ca7810b809ed0f1917293050163c7654cefc57a49f337d5cd9de717b8fad3", - "sha256:118e97556306402e2b010da1ef21ea70cb6d6122e580da64c056b96f524fbd6a", - "sha256:1255d5d64328c5a0d066ecb0f02034d086537925f1f04b50b1ae60d37afbf572", - "sha256:1330f0a4376587face7637dfd245380a57fe21ae8f9d360c1c2ef8746c4195fa", - "sha256:1b8e8cd8032ba266f91136d7105706ad57770f3522eac4a111d77ac126a25a9b", - "sha256:1c6eceb88aaf7221f75be6ab498dc622a151f5f88d536661af3ffc486245a626", - "sha256:1d7fb014fe0fbfee3efd6a94fc635aeaa68e5e1720fe9e57357f2e2c6e1a647e", - "sha256:1db8b2fc7ee8a940b547a14c10e56560ad3ea6499dc6875c354e2335812f739d", - "sha256:2187248203b59625566cac53572ec8c2647a140ee2738b4e36772930377a533c", - "sha256:2b8cf4cfea847d6c12af06091561a89740f1f67f331c3fa8623391905e878530", - "sha256:2bdfe2d7e6c9281c6e55523acd6c2bf77963cb422fdc7d142fb0cb6621b66878", - "sha256:2e8246877afa3f1ae5c979fe85f567d220f86a50dc6c493b9b7d8191181ae01e", - "sha256:36d2fc099229f1e4237f563b2a3e0ff7ccebc3999f729067ce4e64a97a7f2869", - "sha256:37a8a52c3dfff01515e9bbbee0e6063181362f9de3db2ccf9bc96189b557cbfd", - "sha256:3e911391bffdb806001002c1f860787542f45916c3baf764264a52765d5a5603", - "sha256:431ef6937ae0f853143e2ca67d6da76c083e8b1fe3df0e96f3802fd37626e606", - "sha256:437cfd39564744ae32ad5929e55b18ebd88817f9180e4cc05e7d53b75f79ce85", - "sha256:46138f5a0773d064ff663d273b309b696293d7a7c00a0994c5c13a5078134b64", - "sha256:482fe06e9a3fffbcd41950f9d890034b4a54395c60b5e61fae875d37a699813f", - "sha256:49ba23817781e22fcbd45fd9ff2b9b8cdb7b16a42a4851ab8025cae7b22e96d0", - "sha256:4da6fc43048b648275a220e3a61c33b7fff65d11bdd6dcb9d9c145ff708b804c", - "sha256:4def87ef6d90429f777c9d9de3961679abf938cb6b7b63d4a7eb8a268babfce4", - "sha256:4e1be9111cb6756868ac242b3c2bd1f09d9aea09846e4f5c23715e7afb647103", - "sha256:52021b528f1571f98a7d4258c58aa8d4b1a96d4f01d00d51f1089f2e0323cb02", - "sha256:535eec9987adb04701266b92745d6cdcef2e77669299359c3009c3404dd5d191", - "sha256:536a1236065c29980c15c7229fbb830dedf809708c10e159b8136534233545f0", - "sha256:54dece0d21dce4fdb188a1ffc555926adf1d1c516e493c2914d7c370e454bc9e", - "sha256:56a0b8dd6d0d3d971c91f1df75e824986667ccce91e20dca2023683814344791", - "sha256:5c9e89bf19ca148efcc9e3c44fd4c09d5af85c8a7dd3dbd0da1cb83425ef4983", - "sha256:625170a91dd7261a1d1c2a0c1a353c9e55d21cd67d0852185a5fef86587e6f5f", - "sha256:62c828a5b195570eb4b37369fcbbd58e96c905768d53a44d13044355647838ff", - "sha256:62dd7df78e74d924952e2feb7357d826af8d2f307557a779d14ddf94d7311be8", - "sha256:654e4ba1d0b2154ca2f096bed27461cf6160bc7f504a7f9a9ef447c293caf860", - "sha256:69bbd454f0fb23b51cadc9bdba616c9678e4114b6f9fa372d462ff2ed9323ec8", - "sha256:6ac40ec76041c67b928ca8aaffba15c2b2ee3f5ae8d0cb0617b5e63ec119ca25", - "sha256:6ef8e7e8f2f3820c5f175d70fdd199b79e417acf6c72c5d0aa8f63c9f721646f", - "sha256:716bbba09611b4663ecbb7cd022f640759af8259e12a6ca939c0a6acd49eedba", - "sha256:75ecee69073312951244f11b8627e3700ec2bfe07ed24e3a685a5979f0412d24", - "sha256:7613d9977d2ab4a9141dde4a149f4357e4065949674c5649f920fec86ecb393e", - "sha256:777704c1d7655b802c7850255639672e90e81ad6fa42b99ce5ed3fbf45e338dd", - "sha256:77effc978947548b676c54bbd6a08992759ea6f410d4987d69feea9cd0919911", - "sha256:7b0f6cce16306d2e117cf9db71ab3a9e8878a28176aeaf0dbe35248d97b28d0c", - "sha256:7b8c4dc422c1a3ffc550b465090e53b0bf4839047f3e436a34172ac67c45d595", - "sha256:7daa508e75613809c7a57136dec4871a21bca3080b3a8fc347c50b187df4f00c", - "sha256:853ac025092a24bb3bf09ae87f9127de9fe6e0c345614ac92536577cf956dfcc", - "sha256:85ef8d41764c7de0dcdaf64f733a27352248493a85a80661f3c678acd27e31f2", - "sha256:89ffdb799154fd4d7b85c56d5fa9d9ad48946619e0eb95755723fffa11022d75", - "sha256:8b314faa3051a6d45da196a2c495e922f987dc848e967d8cfeaee8a0328b1cd4", - "sha256:8c952f69dd524558694818a461855f35d36cc7f5c0adddce37e962c85d06eac0", - "sha256:8f5fef13136c4e2dee91bfb9a44e236fff78fc2cd9f838eddfc470c3d7d90afe", - "sha256:920b6bd77d209931e4c263223381d63f76828bec574440f29eb497cf3394c249", - "sha256:94bb451c664415f02f07eef4ece976a2c65dcbab9c2f1705b7031a3a75349d8c", - "sha256:95b7a8a3180dfb280eb044fdec562f9b461614c0ef21669aea6f1d3dac6ee576", - "sha256:9799257237d0479736e2b4c01ff26b5c7f7694ac9692a426cb717f3dc02fff9b", - "sha256:9a0318c2068e217a8f5e3b85e35899f5a19e97141a45bb925bb357cfe1daf770", - "sha256:9a79713adfe28830f27a3c62f6b5406c37376c892b05ae070906f07ae4487046", - "sha256:9d124b38b3c299ca68433597ee26b7819209cb8a3a9ea761dfe9db3a04bba584", - "sha256:a2bda8be77660ad4089caf2223fdbd6db1858462c4b85b67fbfa22102021e497", - "sha256:a4d96dc5bcdbd834ec6b0f91027817214216b5b30316494d2b1aebffb87c534f", - "sha256:a66781d7e4cddcbbcfd64de3d41a61d6bdde370fc2e38623f30b2bd539e84a9f", - "sha256:aa733d123cc78245e9bb15f29b44ed9e5780dc6867cfc4e544717b91f980af3b", - "sha256:abc4e880a9b920bc5020bf6a431a6bb40589d9bca3975c980495f63632e8382f", - "sha256:ae8337990e7a45683548ffb2fee1af2f1ed08169284cd829cdd9a7fa7470530d", - "sha256:b11534872256e1666116f6587a1592ef395a98b54476addb5e8d352925cb5d4a", - "sha256:b35298b2db9c2468106278537ee529719228950a5fdda686582f68f247d1dc6e", - "sha256:b99655c32c1c8e06d111e7f41c06c29a5318cb1835df23a45518e02a47c63b68", - "sha256:ba7f8d97152b61f22d7f59491a781ba9b177dd9f318486c5fbc52cde2db12189", - "sha256:bb52c8166499a8150bfd38478248572c924c003cbb45fe3bcd348e5ac7c000f9", - "sha256:c444d824e22da6c9291886d80c7d00c444981a72686e2b59d38b285617cb52c8", - "sha256:c5b9e42ace7d95bf41e19b87cec8f262c41d3510d8ad7514ab3862ea2197bfb1", - "sha256:c6154c3ba59cda3f954c6333025369e42c3acd0c6e8b6ce31eb5c5b8116c07e0", - "sha256:c7c608f833897501dac548585312d73a7dca028bf3b8688f0d712b7acfaf7fb3", - "sha256:ca9973735ce9f770d24d5484dcb42f68f135351c2fc81a7a9369e48cf2998a29", - "sha256:cbb56587a16cf0fb8acd19e90ff9924979ac1431baea8681712716a8337577b0", - "sha256:cdb2c7f071e4026c19a3e32b93a09e59b12000751fc9b0b7758da899e657d215", - "sha256:d108e2d070034f9d57210f22fefd22ea0d04609fc97c5f7f5a686b3471028590", - "sha256:d18689b40cb3936acd971f663ccb8e2589c45db5e2c5f07e0ec6207664029a9c", - "sha256:d1a686d0b0949182b8faddea596f3fc11f44768d1f74d4cad70213b2e139d821", - "sha256:d1eb89bf3454e2132e046f9599fbcf0a4483ed43b40f545551a39316d0201cd1", - "sha256:d3ceb265249fb401702fce3792e6b44c1166b9319737d21495d3611028d95769", - "sha256:da5cb36623f2b846fb25009d9d9215322318ff1c63403075f812b3b2876c8506", - "sha256:da62fecac21a3ee10463d153549d8db87549a5e77eefb8c91ac84bb42bb1e4e3", - "sha256:e18036cb4caa17ea151fd5f3d70be9d354c99eb8cf817a3ccde8a7873b074348", - "sha256:e19c0fc9f4f030fcae43b4cdec9e8ab83ffe30ec10c79a4a43a04d1af6c5e1ad", - "sha256:e1c6bd2c6399920c9622362ce95a7d74e7f9af9bfec05fff91b8ce4b9647845a", - "sha256:e2ada1d8515d3ea5378c018a5f6d14b4994d4036591a52ceaf1a1549dec8e1ad", - "sha256:e4f9373c500dfc02feea39f7a56e4f543e670212102cc2eeb51d3a99c7ffbde6", - "sha256:e67ddb0749ed066b1a03fba812e2dcae791dd50e5da03be50b6a14d0c1a9ee45", - "sha256:e69bb81de06827147b7bfbaeb284d85219fa92d9f097e32cc73675f279d70188", - "sha256:e6e558ea1e47fd6fa8ac9ccdad403e5dd5ecc6ed8dda94343056fa4277d5c65e", - "sha256:ea8e59105d802c5a38bdbe7362822c522230b3faba2aa35c0fa1765239b7dd70", - "sha256:ed5e3a4462ff25ca84fb09e0fada8ea267df98a450340ead4c91b44857267d70", - "sha256:f1a39819a5a3e84304cd286e3dc62a549fe60985415851b3337b6f5cc91907f1", - "sha256:f27a9f9a86dcf00708be82359db8de86b80d029814e6693259befe82bb58a106", - "sha256:f2c7d5aa19714b1b01a0f515d078a629e445e667b9da869a3cd0e6fe7dec78bd", - "sha256:f3a7bb0fe1f7a70fb5c6f497dc32619db7d2cdd53164af30ade2f34673f8b1fc", - "sha256:f4b3cd7334a4bbc0c472164f3744562cb72d05002cc6fcf58adb104630bbc352", - "sha256:f88bc0a2b9c2a835cb888b32246c27cdab5740059fb3688852bf91e915399b91", - "sha256:fb3b309f1d4086b5533cf7bbcf3f956f0ae6469664522f1bde4feed26fba60f1", - "sha256:fc5e64626e6682638d6e44398c9baf1d6ce6bc236d40b4b57255c9d3f9761f1f" - ], - "markers": "python_version >= '3.9'", - "version": "==1.6.0" - }, - "h11": { - "hashes": [ - "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", - "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86" - ], - "markers": "python_version >= '3.8'", - "version": "==0.16.0" - }, - "httpcore": { - "hashes": [ - "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", - "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8" - ], - "markers": "python_version >= '3.8'", - "version": "==1.0.9" - }, - "httpx": { - "hashes": [ - "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", - "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad" - ], - "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==0.28.1" - }, - "humanfriendly": { - "hashes": [ - "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", - "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==10.0" - }, - "idna": { - "hashes": [ - "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", - "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" - ], - "markers": "python_version >= '3.6'", - "version": "==3.10" - }, - "multidict": { - "hashes": [ - "sha256:0327ad2c747a6600e4797d115d3c38a220fdb28e54983abe8964fd17e95ae83c", - "sha256:058cc59b9e9b143cc56715e59e22941a5d868c322242278d28123a5d09cdf6b0", - "sha256:0d2b9712211b860d123815a80b859075d86a4d54787e247d7fbee9db6832cf1c", - "sha256:0e05c39962baa0bb19a6b210e9b1422c35c093b651d64246b6c2e1a7e242d9fd", - "sha256:0f14ea68d29b43a9bf37953881b1e3eb75b2739e896ba4a6aa4ad4c5b9ffa145", - "sha256:169c4ba7858176b797fe551d6e99040c531c775d2d57b31bcf4de6d7a669847f", - "sha256:19d08b4f22eae45bb018b9f06e2838c1e4b853c67628ef8ae126d99de0da6395", - "sha256:1d0121ccce8c812047d8d43d691a1ad7641f72c4f730474878a5aeae1b8ead8c", - "sha256:232b7237e57ec3c09be97206bfb83a0aa1c5d7d377faa019c68a210fa35831f1", - "sha256:2e543a40e4946cf70a88a3be87837a3ae0aebd9058ba49e91cacb0b2cd631e2b", - "sha256:329ae97fc2f56f44d91bc47fe0972b1f52d21c4b7a2ac97040da02577e2daca2", - "sha256:3312f63261b9df49be9d57aaa6abf53a6ad96d93b24f9cc16cf979956355ce6e", - "sha256:33a12ebac9f380714c298cbfd3e5b9c0c4e89c75fe612ae496512ee51028915f", - "sha256:343892a27d1a04d6ae455ecece12904d242d299ada01633d94c4f431d68a8c49", - "sha256:3e9f1cd61a0ab857154205fb0b1f3d3ace88d27ebd1409ab7af5096e409614cd", - "sha256:3ef4e9096ff86dfdcbd4a78253090ba13b1d183daa11b973e842465d94ae1772", - "sha256:4219390fb5bf8e548e77b428bb36a21d9382960db5321b74d9d9987148074d6b", - "sha256:496bcf01c76a70a31c3d746fd39383aad8d685ce6331e4c709e9af4ced5fa221", - "sha256:49a29d7133b1fc214e818bbe025a77cc6025ed9a4f407d2850373ddde07fd04a", - "sha256:4d7b50b673ffb4ff4366e7ab43cf1f0aef4bd3608735c5fbdf0bdb6f690da411", - "sha256:4efc31dfef8c4eeb95b6b17d799eedad88c4902daba39ce637e23a17ea078915", - "sha256:4f5f29794ac0e73d2a06ac03fd18870adc0135a9d384f4a306a951188ed02f95", - "sha256:4ffc3c6a37e048b5395ee235e4a2a0d639c2349dffa32d9367a42fc20d399772", - "sha256:50855d03e9e4d66eab6947ba688ffb714616f985838077bc4b490e769e48da51", - "sha256:51d662c072579f63137919d7bb8fc250655ce79f00c82ecf11cab678f335062e", - "sha256:530d86827a2df6504526106b4c104ba19044594f8722d3e87714e847c74a0275", - "sha256:5363f9b2a7f3910e5c87d8b1855c478c05a2dc559ac57308117424dfaad6805c", - "sha256:55ae0721c1513e5e3210bca4fc98456b980b0c2c016679d3d723119b6b202c42", - "sha256:5883d6ee0fd9d8a48e9174df47540b7545909841ac82354c7ae4cbe9952603bd", - "sha256:5bce06b83be23225be1905dcdb6b789064fae92499fbc458f59a8c0e68718601", - "sha256:5e0ba18a9afd495f17c351d08ebbc4284e9c9f7971d715f196b79636a4d0de44", - "sha256:5e2bcda30d5009996ff439e02a9f2b5c3d64a20151d34898c000a6281faa3781", - "sha256:603f39bd1cf85705c6c1ba59644b480dfe495e6ee2b877908de93322705ad7cf", - "sha256:60d849912350da557fe7de20aa8cf394aada6980d0052cc829eeda4a0db1c1db", - "sha256:622f26ea6a7e19b7c48dd9228071f571b2fbbd57a8cd71c061e848f281550e6b", - "sha256:632a3bf8f1787f7ef7d3c2f68a7bde5be2f702906f8b5842ad6da9d974d0aab3", - "sha256:66ed0731f8e5dfd8369a883b6e564aca085fb9289aacabd9decd70568b9a30de", - "sha256:69133376bc9a03f8c47343d33f91f74a99c339e8b58cea90433d8e24bb298031", - "sha256:69ee9e6ba214b5245031b76233dd95408a0fd57fdb019ddcc1ead4790932a8e8", - "sha256:6a2f58a66fe2c22615ad26156354005391e26a2f3721c3621504cd87c1ea87bf", - "sha256:6a602151dbf177be2450ef38966f4be3467d41a86c6a845070d12e17c858a156", - "sha256:6ed5ae5605d4ad5a049fad2a28bb7193400700ce2f4ae484ab702d1e3749c3f9", - "sha256:73484a94f55359780c0f458bbd3c39cb9cf9c182552177d2136e828269dee529", - "sha256:75493f28dbadecdbb59130e74fe935288813301a8554dc32f0c631b6bdcdf8b0", - "sha256:7cf3bd54c56aa16fdb40028d545eaa8d051402b61533c21e84046e05513d5780", - "sha256:7e23f2f841fcb3ebd4724a40032d32e0892fbba4143e43d2a9e7695c5e50e6bd", - "sha256:7f3d3b3c34867579ea47cbd6c1f2ce23fbfd20a273b6f9e3177e256584f1eacc", - "sha256:82ffabefc8d84c2742ad19c37f02cde5ec2a1ee172d19944d380f920a340e4b9", - "sha256:83ec4967114295b8afd120a8eec579920c882831a3e4c3331d591a8e5bfbbc0f", - "sha256:87a728af265e08f96b6318ebe3c0f68b9335131f461efab2fc64cc84a44aa6ed", - "sha256:87cb72263946b301570b0f63855569a24ee8758aaae2cd182aae7d95fbc92ca7", - "sha256:8adee3ac041145ffe4488ea73fa0a622b464cc25340d98be76924d0cda8545ff", - "sha256:8cc403092a49509e8ef2d2fd636a8ecefc4698cc57bbe894606b14579bc2a955", - "sha256:8cd8f81f1310182362fb0c7898145ea9c9b08a71081c5963b40ee3e3cac589b1", - "sha256:8ffb40b74400e4455785c2fa37eba434269149ec525fc8329858c862e4b35373", - "sha256:93ec84488a384cd7b8a29c2c7f467137d8a73f6fe38bb810ecf29d1ade011a7c", - "sha256:941f1bec2f5dbd51feeb40aea654c2747f811ab01bdd3422a48a4e4576b7d76a", - "sha256:98af87593a666f739d9dba5d0ae86e01b0e1a9cfcd2e30d2d361fbbbd1a9162d", - "sha256:995f985e2e268deaf17867801b859a282e0448633f1310e3704b30616d269d69", - "sha256:9abcf56a9511653fa1d052bfc55fbe53dbee8f34e68bd6a5a038731b0ca42d15", - "sha256:9bbf7bd39822fd07e3609b6b4467af4c404dd2b88ee314837ad1830a7f4a8299", - "sha256:9c17341ee04545fd962ae07330cb5a39977294c883485c8d74634669b1f7fe04", - "sha256:9f193eeda1857f8e8d3079a4abd258f42ef4a4bc87388452ed1e1c4d2b0c8740", - "sha256:9faf1b1dcaadf9f900d23a0e6d6c8eadd6a95795a0e57fcca73acce0eb912065", - "sha256:9fcad2945b1b91c29ef2b4050f590bfcb68d8ac8e0995a74e659aa57e8d78e01", - "sha256:a145c550900deb7540973c5cdb183b0d24bed6b80bf7bddf33ed8f569082535e", - "sha256:a4d1cb1327c6082c4fce4e2a438483390964c02213bc6b8d782cf782c9b1471f", - "sha256:a887b77f51d3d41e6e1a63cf3bc7ddf24de5939d9ff69441387dfefa58ac2e26", - "sha256:a920f9cf2abdf6e493c519492d892c362007f113c94da4c239ae88429835bad1", - "sha256:aff4cafea2d120327d55eadd6b7f1136a8e5a0ecf6fb3b6863e8aca32cd8e50a", - "sha256:b0f1987787f5f1e2076b59692352ab29a955b09ccc433c1f6b8e8e18666f608b", - "sha256:b308402608493638763abc95f9dc0030bbd6ac6aff784512e8ac3da73a88af08", - "sha256:b61e98c3e2a861035aaccd207da585bdcacef65fe01d7a0d07478efac005e028", - "sha256:b9eb4c59c54421a32b3273d4239865cb14ead53a606db066d7130ac80cc8ec93", - "sha256:ba852168d814b2c73333073e1c7116d9395bea69575a01b0b3c89d2d5a87c8fb", - "sha256:bb5ac9e5bfce0e6282e7f59ff7b7b9a74aa8e5c60d38186a4637f5aa764046ad", - "sha256:bb61ffd3ab8310d93427e460f565322c44ef12769f51f77277b4abad7b6f7223", - "sha256:bbfcb60396f9bcfa63e017a180c3105b8c123a63e9d1428a36544e7d37ca9e20", - "sha256:bd4557071b561a8b3b6075c3ce93cf9bfb6182cb241805c3d66ced3b75eff4ac", - "sha256:be06e73c06415199200e9a2324a11252a3d62030319919cde5e6950ffeccf72e", - "sha256:c04157266344158ebd57b7120d9b0b35812285d26d0e78193e17ef57bfe2979a", - "sha256:c10d17371bff801af0daf8b073c30b6cf14215784dc08cd5c43ab5b7b8029bbc", - "sha256:c27e5dcf520923d6474d98b96749e6805f7677e93aaaf62656005b8643f907ab", - "sha256:c93a6fb06cc8e5d3628b2b5fda215a5db01e8f08fc15fadd65662d9b857acbe4", - "sha256:cbebaa076aaecad3d4bb4c008ecc73b09274c952cf6a1b78ccfd689e51f5a5b0", - "sha256:cc5d83c6619ca5c9672cb78b39ed8542f1975a803dee2cda114ff73cbb076edd", - "sha256:d1a20707492db9719a05fc62ee215fd2c29b22b47c1b1ba347f9abc831e26683", - "sha256:d1f7cbd4f1f44ddf5fd86a8675b7679176eae770f2fc88115d6dddb6cefb59bc", - "sha256:d21c1212171cf7da703c5b0b7a0e85be23b720818aef502ad187d627316d5645", - "sha256:d2fa86af59f8fc1972e121ade052145f6da22758f6996a197d69bb52f8204e7e", - "sha256:d5b1cc3ab8c31d9ebf0faa6e3540fb91257590da330ffe6d2393d4208e638925", - "sha256:d693307856d1ef08041e8b6ff01d5b4618715007d288490ce2c7e29013c12b9a", - "sha256:d6b15c55721b1b115c5ba178c77104123745b1417527ad9641a4c5e2047450f0", - "sha256:d832c608f94b9f92a0ec8b7e949be7792a642b6e535fcf32f3e28fab69eeb046", - "sha256:d83f18315b9fca5db2452d1881ef20f79593c4aa824095b62cb280019ef7aa3d", - "sha256:d877447e7368c7320832acb7159557e49b21ea10ffeb135c1077dbbc0816b598", - "sha256:dc388f75a1c00000824bf28b7633e40854f4127ede80512b44c3cfeeea1839a2", - "sha256:dc8c9736d8574b560634775ac0def6bdc1661fc63fa27ffdfc7264c565bcb4f2", - "sha256:e32053d6d3a8b0dfe49fde05b496731a0e6099a4df92154641c00aa76786aef5", - "sha256:e5f8a146184da7ea12910a4cec51ef85e44f6268467fb489c3caf0cd512f29c2", - "sha256:ecde56ea2439b96ed8a8d826b50c57364612ddac0438c39e473fafad7ae1c23b", - "sha256:f682c42003c7264134bfe886376299db4cc0c6cd06a3295b41b347044bcb5482", - "sha256:fad6daaed41021934917f4fb03ca2db8d8a4d79bf89b17ebe77228eb6710c003", - "sha256:fc60f91c02e11dfbe3ff4e1219c085695c339af72d1641800fe6075b91850c8f" - ], - "markers": "python_version >= '3.9'", - "version": "==6.4.4" - }, - "mutagen": { - "hashes": [ - "sha256:719fadef0a978c31b4cf3c956261b3c58b6948b32023078a2117b1de09f0fc99", - "sha256:edd96f50c5907a9539d8e5bba7245f62c9f520aef333d13392a79a4f70aca719" - ], - "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==1.47.0" - }, - "propcache": { - "hashes": [ - "sha256:050b571b2e96ec942898f8eb46ea4bfbb19bd5502424747e83badc2d4a99a44e", - "sha256:05543250deac8e61084234d5fc54f8ebd254e8f2b39a16b1dce48904f45b744b", - "sha256:069e7212890b0bcf9b2be0a03afb0c2d5161d91e1bf51569a64f629acc7defbf", - "sha256:09400e98545c998d57d10035ff623266927cb784d13dd2b31fd33b8a5316b85b", - "sha256:0c3c3a203c375b08fd06a20da3cf7aac293b834b6f4f4db71190e8422750cca5", - "sha256:0c86e7ceea56376216eba345aa1fc6a8a6b27ac236181f840d1d7e6a1ea9ba5c", - "sha256:0fbe94666e62ebe36cd652f5fc012abfbc2342de99b523f8267a678e4dfdee3c", - "sha256:17d1c688a443355234f3c031349da69444be052613483f3e4158eef751abcd8a", - "sha256:19a06db789a4bd896ee91ebc50d059e23b3639c25d58eb35be3ca1cbe967c3bf", - "sha256:1c5c7ab7f2bb3f573d1cb921993006ba2d39e8621019dffb1c5bc94cdbae81e8", - "sha256:1eb34d90aac9bfbced9a58b266f8946cb5935869ff01b164573a7634d39fbcb5", - "sha256:1f6cc0ad7b4560e5637eb2c994e97b4fa41ba8226069c9277eb5ea7101845b42", - "sha256:27c6ac6aa9fc7bc662f594ef380707494cb42c22786a558d95fcdedb9aa5d035", - "sha256:2d219b0dbabe75e15e581fc1ae796109b07c8ba7d25b9ae8d650da582bed01b0", - "sha256:2fce1df66915909ff6c824bbb5eb403d2d15f98f1518e583074671a30fe0c21e", - "sha256:319fa8765bfd6a265e5fa661547556da381e53274bc05094fc9ea50da51bfd46", - "sha256:359e81a949a7619802eb601d66d37072b79b79c2505e6d3fd8b945538411400d", - "sha256:3a02a28095b5e63128bcae98eb59025924f121f048a62393db682f049bf4ac24", - "sha256:3e19ea4ea0bf46179f8a3652ac1426e6dcbaf577ce4b4f65be581e237340420d", - "sha256:3e584b6d388aeb0001d6d5c2bd86b26304adde6d9bb9bfa9c4889805021b96de", - "sha256:40d980c33765359098837527e18eddefc9a24cea5b45e078a7f3bb5b032c6ecf", - "sha256:4114c4ada8f3181af20808bedb250da6bae56660e4b8dfd9cd95d4549c0962f7", - "sha256:43593c6772aa12abc3af7784bff4a41ffa921608dd38b77cf1dfd7f5c4e71371", - "sha256:47ef24aa6511e388e9894ec16f0fbf3313a53ee68402bc428744a367ec55b833", - "sha256:4cf9e93a81979f1424f1a3d155213dc928f1069d697e4353edb8a5eba67c6259", - "sha256:4d0dfdd9a2ebc77b869a0b04423591ea8823f791293b527dc1bb896c1d6f1136", - "sha256:563f9d8c03ad645597b8d010ef4e9eab359faeb11a0a2ac9f7b4bc8c28ebef25", - "sha256:58aa11f4ca8b60113d4b8e32d37e7e78bd8af4d1a5b5cb4979ed856a45e62005", - "sha256:5a0a9898fdb99bf11786265468571e628ba60af80dc3f6eb89a3545540c6b0ef", - "sha256:5aed8d8308215089c0734a2af4f2e95eeb360660184ad3912686c181e500b2e7", - "sha256:5b9145c35cc87313b5fd480144f8078716007656093d23059e8993d3a8fa730f", - "sha256:5cb5918253912e088edbf023788de539219718d3b10aef334476b62d2b53de53", - "sha256:5cdb0f3e1eb6dfc9965d19734d8f9c481b294b5274337a8cb5cb01b462dcb7e0", - "sha256:5ced33d827625d0a589e831126ccb4f5c29dfdf6766cac441d23995a65825dcb", - "sha256:603f1fe4144420374f1a69b907494c3acbc867a581c2d49d4175b0de7cc64566", - "sha256:61014615c1274df8da5991a1e5da85a3ccb00c2d4701ac6f3383afd3ca47ab0a", - "sha256:64a956dff37080b352c1c40b2966b09defb014347043e740d420ca1eb7c9b908", - "sha256:668ddddc9f3075af019f784456267eb504cb77c2c4bd46cc8402d723b4d200bf", - "sha256:6d8e309ff9a0503ef70dc9a0ebd3e69cf7b3894c9ae2ae81fc10943c37762458", - "sha256:6f173bbfe976105aaa890b712d1759de339d8a7cef2fc0a1714cc1a1e1c47f64", - "sha256:71ebe3fe42656a2328ab08933d420df5f3ab121772eef78f2dc63624157f0ed9", - "sha256:730178f476ef03d3d4d255f0c9fa186cb1d13fd33ffe89d39f2cda4da90ceb71", - "sha256:7d2d5a0028d920738372630870e7d9644ce437142197f8c827194fca404bf03b", - "sha256:7f30241577d2fef2602113b70ef7231bf4c69a97e04693bde08ddab913ba0ce5", - "sha256:813fbb8b6aea2fc9659815e585e548fe706d6f663fa73dff59a1677d4595a037", - "sha256:82de5da8c8893056603ac2d6a89eb8b4df49abf1a7c19d536984c8dd63f481d5", - "sha256:83be47aa4e35b87c106fc0c84c0fc069d3f9b9b06d3c494cd404ec6747544894", - "sha256:8638f99dca15b9dff328fb6273e09f03d1c50d9b6512f3b65a4154588a7595fe", - "sha256:87380fb1f3089d2a0b8b00f006ed12bd41bd858fabfa7330c954c70f50ed8757", - "sha256:88c423efef9d7a59dae0614eaed718449c09a5ac79a5f224a8b9664d603f04a3", - "sha256:89498dd49c2f9a026ee057965cdf8192e5ae070ce7d7a7bd4b66a8e257d0c976", - "sha256:8a17583515a04358b034e241f952f1715243482fc2c2945fd99a1b03a0bd77d6", - "sha256:916cd229b0150129d645ec51614d38129ee74c03293a9f3f17537be0029a9641", - "sha256:9532ea0b26a401264b1365146c440a6d78269ed41f83f23818d4b79497aeabe7", - "sha256:967a8eec513dbe08330f10137eacb427b2ca52118769e82ebcfcab0fba92a649", - "sha256:975af16f406ce48f1333ec5e912fe11064605d5c5b3f6746969077cc3adeb120", - "sha256:9979643ffc69b799d50d3a7b72b5164a2e97e117009d7af6dfdd2ab906cb72cd", - "sha256:9a8ecf38de50a7f518c21568c80f985e776397b902f1ce0b01f799aba1608b40", - "sha256:9cec3239c85ed15bfaded997773fdad9fb5662b0a7cbc854a43f291eb183179e", - "sha256:9e64e948ab41411958670f1093c0a57acfdc3bee5cf5b935671bbd5313bcf229", - "sha256:9f64d91b751df77931336b5ff7bafbe8845c5770b06630e27acd5dbb71e1931c", - "sha256:a0ab8cf8cdd2194f8ff979a43ab43049b1df0b37aa64ab7eca04ac14429baeb7", - "sha256:a110205022d077da24e60b3df8bcee73971be9575dec5573dd17ae5d81751111", - "sha256:a34aa3a1abc50740be6ac0ab9d594e274f59960d3ad253cd318af76b996dd654", - "sha256:a444192f20f5ce8a5e52761a031b90f5ea6288b1eef42ad4c7e64fef33540b8f", - "sha256:a461959ead5b38e2581998700b26346b78cd98540b5524796c175722f18b0294", - "sha256:a75801768bbe65499495660b777e018cbe90c7980f07f8aa57d6be79ea6f71da", - "sha256:aa8efd8c5adc5a2c9d3b952815ff8f7710cefdcaf5f2c36d26aff51aeca2f12f", - "sha256:aca63103895c7d960a5b9b044a83f544b233c95e0dcff114389d64d762017af7", - "sha256:b0313e8b923b3814d1c4a524c93dfecea5f39fa95601f6a9b1ac96cd66f89ea0", - "sha256:b23c11c2c9e6d4e7300c92e022046ad09b91fd00e36e83c44483df4afa990073", - "sha256:b303b194c2e6f171cfddf8b8ba30baefccf03d36a4d9cab7fd0bb68ba476a3d7", - "sha256:b655032b202028a582d27aeedc2e813299f82cb232f969f87a4fde491a233f11", - "sha256:bd39c92e4c8f6cbf5f08257d6360123af72af9f4da75a690bef50da77362d25f", - "sha256:bef100c88d8692864651b5f98e871fb090bd65c8a41a1cb0ff2322db39c96c27", - "sha256:c2fe5c910f6007e716a06d269608d307b4f36e7babee5f36533722660e8c4a70", - "sha256:c66d8ccbc902ad548312b96ed8d5d266d0d2c6d006fd0f66323e9d8f2dd49be7", - "sha256:cd6a55f65241c551eb53f8cf4d2f4af33512c39da5d9777694e9d9c60872f519", - "sha256:d249609e547c04d190e820d0d4c8ca03ed4582bcf8e4e160a6969ddfb57b62e5", - "sha256:d4e89cde74154c7b5957f87a355bb9c8ec929c167b59c83d90654ea36aeb6180", - "sha256:dc1915ec523b3b494933b5424980831b636fe483d7d543f7afb7b3bf00f0c10f", - "sha256:e1c4d24b804b3a87e9350f79e2371a705a188d292fd310e663483af6ee6718ee", - "sha256:e474fc718e73ba5ec5180358aa07f6aded0ff5f2abe700e3115c37d75c947e18", - "sha256:e4fe2a6d5ce975c117a6bb1e8ccda772d1e7029c1cca1acd209f91d30fa72815", - "sha256:e7fb9a84c9abbf2b2683fa3e7b0d7da4d8ecf139a1c635732a8bda29c5214b0e", - "sha256:e861ad82892408487be144906a368ddbe2dc6297074ade2d892341b35c59844a", - "sha256:ec314cde7314d2dd0510c6787326bbffcbdc317ecee6b7401ce218b3099075a7", - "sha256:ed5f6d2edbf349bd8d630e81f474d33d6ae5d07760c44d33cd808e2f5c8f4ae6", - "sha256:ef2e4e91fb3945769e14ce82ed53007195e616a63aa43b40fb7ebaaf907c8d4c", - "sha256:f011f104db880f4e2166bcdcf7f58250f7a465bc6b068dc84c824a3d4a5c94dc", - "sha256:f1528ec4374617a7a753f90f20e2f551121bb558fcb35926f99e3c42367164b8", - "sha256:f27785888d2fdd918bc36de8b8739f2d6c791399552333721b58193f68ea3e98", - "sha256:f35c7070eeec2cdaac6fd3fe245226ed2a6292d3ee8c938e5bb645b434c5f256", - "sha256:f3bbecd2f34d0e6d3c543fdb3b15d6b60dd69970c2b4c822379e5ec8f6f621d5", - "sha256:f6f1324db48f001c2ca26a25fa25af60711e09b9aaf4b28488602776f4f9a744", - "sha256:f78eb8422acc93d7b69964012ad7048764bb45a54ba7a39bb9e146c72ea29723", - "sha256:fb6e0faf8cb6b4beea5d6ed7b5a578254c6d7df54c36ccd3d8b3eb00d6770277", - "sha256:feccd282de1f6322f56f6845bf1207a537227812f0a9bf5571df52bb418d79d5" - ], - "markers": "python_version >= '3.9'", - "version": "==0.3.1" - }, - "pycparser": { - "hashes": [ - "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", - "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc" - ], - "markers": "python_version >= '3.8'", - "version": "==2.22" - }, - "pycryptodome": { - "hashes": [ - "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", - "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", - "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630", - "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", - "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", - "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", - "sha256:350ebc1eba1da729b35ab7627a833a1a355ee4e852d8ba0447fafe7b14504d56", - "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", - "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", - "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", - "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", - "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", - "sha256:573a0b3017e06f2cffd27d92ef22e46aa3be87a2d317a5abf7cc0e84e321bd75", - "sha256:63dad881b99ca653302b2c7191998dd677226222a3f2ea79999aa51ce695f720", - "sha256:64093fc334c1eccfd3933c134c4457c34eaca235eeae49d69449dc4728079339", - "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", - "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", - "sha256:6fe8258e2039eceb74dfec66b3672552b6b7d2c235b2dfecc05d16b8921649a8", - "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", - "sha256:7ac1080a8da569bde76c0a104589c4f414b8ba296c0b3738cf39a466a9fb1818", - "sha256:865d83c906b0fc6a59b510deceee656b6bc1c4fa0d82176e2b77e97a420a996a", - "sha256:89d4d56153efc4d81defe8b65fd0821ef8b2d5ddf8ed19df31ba2f00872b8002", - "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", - "sha256:93837e379a3e5fd2bb00302a47aee9fdf7940d83595be3915752c74033d17ca7", - "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", - "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", - "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", - "sha256:a176b79c49af27d7f6c12e4b178b0824626f40a7b9fed08f712291b6d54bf566", - "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353", - "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", - "sha256:b34e8e11d97889df57166eda1e1ddd7676da5fcd4d71a0062a760e75060514b4", - "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", - "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", - "sha256:ce64e84a962b63a47a592690bdc16a7eaf709d2c2697ababf24a0def566899a6", - "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", - "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4", - "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", - "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379", - "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", - "sha256:e3f2d0aaf8080bda0587d58fc9fe4766e012441e2eed4269a77de6aea981c8be", - "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7" - ], - "index": "pypi", - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", - "version": "==3.23.0" - }, - "pyjson5": { - "hashes": [ - "sha256:001440b986f226e65237179a2a9721f0e78d6e75a7779d9941d0f10522fce12c", - "sha256:0014adf743c4093df511934a6bca5b1e3592b36b96ff86bd74b0c4de05beafe1", - "sha256:03742b454bede9a61c2003574b74c8ba5de5d8363b0630f89a164d17bb7562a8", - "sha256:09cbd1dc018bf5d3ec34128635df0545f2074bb019c00e37b885a03a454eef00", - "sha256:0ad9c24d20ce825df70701361c7b7da04f703f8f5ff3cf565b156ff02e24db02", - "sha256:0ae55ee48ffb5a7f9fc22c135d38f31fc2db43b8dfc69b1285476123fe641697", - "sha256:10412ccc4fffbcd07187e4b0016bbedb1a22adf7bcd8eb4c78823a7e3cf32b9f", - "sha256:1442d68d81b5fd61e98fbb19be1bf6fe845bc0550fc37660d415f7f7bd7cfe27", - "sha256:1f1f9bf8a097f3c2436028be563ba6fbce75cb48c1a7142c67e935fd8b63ae07", - "sha256:20c2333f45497452adf803dbc24aedd471303d22942cc26b3806bbd7ca668608", - "sha256:21d7687a9377b05d112580f13f6b03555a2da1555e44c592902026bc14a98989", - "sha256:22dc2201250b665771669093894311991d6517f92a78efb2d63dae2353132180", - "sha256:27488b7a015077ccf800db536fc52d0fd870c7493f92db7f5f27e3150ac4c3f9", - "sha256:28bce6656db6552ae05d6d1117793ee19113b23923a47586f684d9b0a13da0e8", - "sha256:2ba2866a42a31b0aaab292f141f6b535cca47983786ed38f0ab7e128c0c70d27", - "sha256:2bbb2bb8af94128cb05361c6d8dd3cdca240e8c02920d6723e93b7d8c127b9d7", - "sha256:2f0096855d931023c23846537b53c1af3f070ac2cb71a44a516fb80550516167", - "sha256:30cf465a427d0264e2e71b8cb75d606eca00090b4eeadf9ac18f691e032b89c7", - "sha256:3541f78103dab48a43326c4fe47a6175c21b76cf8104f58d8a9822fc6f54fd6d", - "sha256:364372ebcc1770abd35c6a67e0716d3419117d536e1383baed45de364ecfaace", - "sha256:384ce0c40ae635f6cb9f3b653bbf6e93f10207a98d51301805a37a0a47d168da", - "sha256:39b0e9641ca70efa6f7ff725afda83e3eb4a84b1a97d0c38aa13abe5622c4e1d", - "sha256:406eaf4bb7e5354f5d3f5e1d34f7afd1d971696798cba88e65a53971b5d557e7", - "sha256:429256f888ce0d61066f070ad696ded9c6d62a8570aef6b683356f9ca0d8f9c0", - "sha256:452ddb98b1ccf738dc722d3ce7fa7640c8f7345d68dc077c383fb141e35b88da", - "sha256:46841aed0b414aabbedc9d0df4d0568833c5c6c29d58a4550575a4a4b6365206", - "sha256:469abb3e01556e608f4931bf4217af575c51fbf2e3b05067b6ab2f24ae59015b", - "sha256:5382de2408dc5e66cf397558f47fa99c19c0b52082453350d952ae371150b8f1", - "sha256:55f299cc8ee717951cb5ba866d85ab2d6c083965d47f4568b340591a2145136c", - "sha256:5797c32db7acc41402b0c999f4f0cf1ed7ffdd9df2b2d84bc754e59c644999d0", - "sha256:58020a57d49c6baf142c6891fc932cdc7474020841eb624918ab3a5af4694a47", - "sha256:5809a30c8bdb68831cc7c00cc260b3a3e8185b04516058394717f90d9f00f428", - "sha256:594ceb310e99d9fe0d3e74361841cc535561ef52a79c0aaa8b6d640d11f43eb2", - "sha256:5c589dc2bb48e6229f6fddf0f86634534b24f19960273475429e3ba690118a96", - "sha256:5c91a06dad5cb73127b0ef4e1befff836f65244278372a94751367dfb0a80af5", - "sha256:5e334220158e3789fb1c7e67ca200ade1d17c70ddd1373dc1d39d5808645905f", - "sha256:5efd518c27b63f74c59f79aa9e3892f6d18cef9fc400be3bb05a12b90896a97e", - "sha256:632d331ba0de878e5a8a4fe4a3bc7a68225dcd87ba87a429ee92434535d057ca", - "sha256:64d04cc1fd7cc2e02e770db9f9ddde69d95f6385a17f135642a1d9b15d6ca326", - "sha256:6a307e7bc6df85f3b1b54f62ce0dc2f784115b839d8d2d949705a11e2b0ad517", - "sha256:6aea9a2bf0c66ca9a966c9198949c1efdf6a61c4e00d42cb4f3047d4b3b0eb3e", - "sha256:6f568fd0b0adc4f152aaa58493ab88543289ee30a2b5a1d20cbc63cae12fe3c5", - "sha256:70910ffadb8f10e69a9a379cd4712ab41785957f6b739cfe3c9c14146eae1fcd", - "sha256:7112cf79212c5634f6af1e15e26998690cd6e4393243fd62570702efb224dc54", - "sha256:74cb4e173de02d78bca5e21e0ed433df880f4e976104e49a559ee430545f6bbb", - "sha256:759a6742b72045cfdf188d3af7058dc8a8d63e47b265c344a2a68dc40d156bf3", - "sha256:7709c097d60518d0be59504020b4549694ffd01c2bccb583bf489a3de8f54da8", - "sha256:7bd659d782bb5b5ef84fbfcd57cdef19c4178a4c83bb0dc82b5e7d5157d17d15", - "sha256:81248399ee082aecaa6b5c5019063826c5bd80c6bbb39a67952f7cbeffa24fd9", - "sha256:82eff8cecd2744c5c9becc0c60fd15b8d96f272e358dcb27c993d138ff09e650", - "sha256:82fdeb5754a772d8ed7d67982a3f64fb9b1eb0b3fb6daf7a061921eefba23326", - "sha256:8524228411e312f19afb417d3dae4e34a2f4f7094fb992aa93b9413111fd1765", - "sha256:86650998b4d0d08795429ee1814342833896989d65d8dc1deb9a544cd2d38434", - "sha256:88e784397d44c14c70af472e7e0d5e141d8ec67a406aaa1e465ed0e0a0b62dd1", - "sha256:897f8534a384ab10e11140c0852f1397af2d8d7c064b50eb41a1c6973da95a2a", - "sha256:8b1c159806c6967c8e2d32bb2d9f99f610071777f84133be5479ce1b318330b7", - "sha256:8b9e409f0eca16f3fbbf31f0d6e50b1779a88c8b8c550acc92604b2e3bca1450", - "sha256:8fb78a9da81674b13df0ebf7b299897b0aa734a944028b639743225c6aa4caac", - "sha256:90adaebc95819ed5fdb84881ae9083867cc5fd646c665433fd19459eb1ef1f7c", - "sha256:90c6ba0134a9217ee6dfed91a0020aa9d0366d27b1bbee63e6ed8b1f46ee813f", - "sha256:92a80fce259648ba939c4783064c0245ad841881d73db9da9c4877d6c6ccc4e7", - "sha256:9398dfc696cdc45a9dcdd1c50051c5e259014d99482857ebea164201fafdbad0", - "sha256:93c3f8feb95e599d1a1b87291b0c14693775512bdda4bf95dcef83c5d80e9c69", - "sha256:9444ae9f8982b584ae2dbbecdd510189de577e879f794619afc62b5ea9109d26", - "sha256:94e6005e98ef5defb6c7aa3d7010723cb8faf3cf878b919241c9a857f95ad7e3", - "sha256:950f4d162338f3c6223875d8eeb097b8ca76a5716664777594ef3b755bfdb936", - "sha256:9676a20a7fa05463e143bb6c2a479f0c4530ef9f81c0d1af764452f0ee3640c3", - "sha256:96ea440ff46d5f05d79e09582007b4d887111dcbf971900a9c40e8de83e48d2d", - "sha256:97401f7465dcba52da29d63718b2b69e2ca9b51cee64bdf0ae79279ebf100250", - "sha256:9aff233d66cb08f5fb4bab34bffc6007b71acff508607e723ebea3d0ec78236e", - "sha256:a4cf7e9f9a2f1194178864de70c7e152b0f07fa23ea3a8dcbcf35e8399403477", - "sha256:a5ee5b7eddc642595b648edf0ee9af5f997ded3e5cda4345b2a8de01766ccdb6", - "sha256:a719b276cacea63b28ab1d5de0f136a26732a7bedc7b5ef1a0c5a5747d852b42", - "sha256:a7c6fb7a1f1ffcd266ededfe3dec5bf68989ada9fedb238a3260d020386ea81c", - "sha256:a8ee704fee7e60767bcc91ac510a87ee551e2eb85e01a1353e67d8c3add0ce2a", - "sha256:aac39b8f1a9d0eea4e275ab2c4c45afc8221718b06444b12fc3ee4acb65ff7ce", - "sha256:ab3c9783fb8a1b4362a6e7ce242e3c0a0e9ba4f372a678ad1802bb6199864d74", - "sha256:b1b7909ca28e332979006692af9e3079e0e6fe883f0726201b67271e45f7871a", - "sha256:b1e71df87ee24b45b1e680d6efa75cacafc488d861285a983adf348576d264e7", - "sha256:b4355d5f9edba434ef2a474d3fbfd91821be54e86b959ab12ccb1b48c9a872fe", - "sha256:b7441803def1be9cf6e1f42d550f2ec37b459aa854cea4da5ecc8e03a9b52ed8", - "sha256:b8525cd88dba52b55ce5a4efaaaa05378c596ff6ec1ee7a500cca9fac12a001d", - "sha256:bd7453845bfcb5e3af55206184da28af2a5beb8eaf5196dc2da81a7ba90fc12c", - "sha256:bd84d94e8422e14292f1acc8edcc7deaee313eb7475c113ba77a16c0ac384160", - "sha256:beb673830e1c8db01861cab2df06af51480091d69063d028d6f77557fa58eb05", - "sha256:c04a350059667d5fa6768e0f2b2ae874ccf402309d8132dceacdf8a92f9a5bd9", - "sha256:c39c6bc7a502778d860b5f8b09fe72c4c9d75a073233ffc5dee96df3290d9d16", - "sha256:c5e6b7fcbcaa08a8b59ff6b17162fde443d61ede8f8165bd67fa5719032ecd5e", - "sha256:c626104ecf59907976ccccc8d47f0e07e3c2b1c45b21efcc84c71f96643b0f81", - "sha256:c89596795f24d9e3041867e8d801caeb46f0cf94e858f1820d71c185818c10ab", - "sha256:c94a85e46b3b9039d630bfcf8f0656dd205e921cad285d483940a2b0e79eabea", - "sha256:cc2715ddadd685f674329a7aa48e5fdbbb96346d6f61981027e1cf4c70632067", - "sha256:ced7da1c042fe086674bcd28817516fb858617af5a547862621f94827fe70d2f", - "sha256:d16dd99a8c8ca55e446633c6346fc4de30a4eff5eaf0d8376db95965e5acaf8f", - "sha256:d2420b2ee2d68ce56f4c22fb471f738006ec0977e3666a21a9aaf441d963c38c", - "sha256:d36ec72d063c4f1ea46b1d83f2fcdf63ba18c201a128f552e06d5f385f80e6f2", - "sha256:d442b6575a1e002adfb1c99ae3002632ab2fdb4859150fdac1414974805e5fb7", - "sha256:d55001960b1d9a6455e81804fbf6542b3d57b92c91166e55549eda52da9f3af0", - "sha256:d5c93042513df889a4fe05c9dbf5c47c7a5a05c54cc8a0094d685a9199efbd39", - "sha256:d6e678a71f8125bade49683ad60fa9798f8502d35eef2a93f13a5684bfe534b4", - "sha256:d71eae5c6c72f003e1303ce73f76f7a765e5d5cd216a4aae1c2e7711ba4c0dd2", - "sha256:d9330aed86005d00339e65aca95cfe388e0d5b91a0fe063da8032be51b083130", - "sha256:dbfeee33abe68c7c0952c3d9076e31056aba9a0877339dd85683dd63742f5e95", - "sha256:dc918fc4d88643763304f18a9742cbdff45b9163dc5e4cb3a38e2accedb67c5e", - "sha256:df4f548fed26561c53cc0a8a19432b0f74e0837484b4b06bbe5eb951abbb4c06", - "sha256:e0c37d6ab63df496d01ecadf957a74879285ed7a7f6500af3c14e09c073a2c9b", - "sha256:e22567f2533350f5d7ddafb76b5f5449867a44d8bbc85fb82cf27dde08f05eee", - "sha256:e229fddb8e5dc7696a438cf72e279540ea281c2dae56b93c7c91bae14d812a93", - "sha256:e3468a3b8509648cf4d92ebc05fe94261330829137f225621b4778796b067c68", - "sha256:e6f670d9df62fa3b3fe724ee837950d855929617a854348846a9b5975096eb7d", - "sha256:e859101d682399a72cd1d96b0cfcb7110c054b870f0389d86e014161a21cdcdd", - "sha256:e85cd01a583a02e5bda920e69d7dd425eba0ac9a6ff5be5c40abff8c2aa3b0fa", - "sha256:e8e6c4025878cd3e97af3eecfabfa088a17798cf8dbf42b60b57c81f494586a3", - "sha256:f4a3215d5ecab101e0e9752d4aec6935ab45a82f6f0282283307bbab495c9677", - "sha256:f4f6dc39723048aa6d4816bb52e8ed50b5886b5ae92c3d6b4a7d62e6cf544779", - "sha256:f51de3798f4f056a9dc27d8a845fcf1b317fa7eb30b33753e0b3190208b68a57", - "sha256:f5d48146117c3f6e16a0d4da0a4607a3d148894cc248ab6af4bd1832c6d4eac6", - "sha256:f77023f210edf75764b3392d5b58736bfe42c80546e3f1b8e7bad182fdc4685d", - "sha256:f93192935e2cef0cc4eb675c2b2a0ec8e68a8efac890259351c8d7802a7193fe", - "sha256:f9411294f7bb8d842c348aae59aa5b33ad8e76ce4a3a4ad9665d8fd04e018c0a", - "sha256:fcb910e843e119bbe90f3898d5a95d1d0f452bb88b29614f76015b9b15fa5753", - "sha256:fed7cf6605bbdb310395d81cc8abb4403ed88ae1f3c37256445e9c01ea073eab", - "sha256:ffd652318c2b483b4321a06c74ffe9281d8b795c996abcf6d6010129729f652e" - ], - "index": "pypi", - "markers": "python_version ~= '3.7'", - "version": "==1.6.9" - }, - "pysubs2": { - "hashes": [ - "sha256:05716f5039a9ebe32cd4d7673f923cf36204f3a3e99987f823ab83610b7035a0", - "sha256:3397bb58a4a15b1325ba2ae3fd4d7c214e2c0ddb9f33190d6280d783bb433b20" - ], - "index": "pypi", - "markers": "python_version >= '3.9'", - "version": "==1.8.0" - }, - "python-dateutil": { - "hashes": [ - "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", - "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.9.0.post0" - }, - "python-dotenv": { - "hashes": [ - "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", - "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d" - ], - "index": "pypi", - "markers": "python_version >= '3.9'", - "version": "==1.1.0" - }, - "python-engineio": { - "hashes": [ - "sha256:9ec20d7900def0886fb9621f86fd1f05140d407f8d4e6a51bef0cfba2d112ff7", - "sha256:9f2b5a645c416208a9c727254316d487252493de52bee0ff70dc29ca9210397e" - ], - "markers": "python_version >= '3.6'", - "version": "==4.12.1" - }, - "python-magic": { - "hashes": [ - "sha256:c1ba14b08e4a5f5c31a302b7721239695b2f0f058d125bd5ce1ee36b9d9d3c3b", - "sha256:c212960ad306f700aa0d01e5d7a325d20548ff97eb9920dcd29513174f0294d3" - ], - "index": "pypi", - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==0.4.27" - }, - "python-socketio": { - "hashes": [ - "sha256:51f68d6499f2df8524668c24bcec13ba1414117cfb3a90115c559b601ab10caf", - "sha256:ac4e19a0302ae812e23b712ec8b6427ca0521f7c582d6abb096e36e24a263029" - ], - "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==5.13.0" - }, - "regex": { - "hashes": [ - "sha256:02a02d2bb04fec86ad61f3ea7f49c015a0681bf76abb9857f945d26159d2968c", - "sha256:02e28184be537f0e75c1f9b2f8847dc51e08e6e171c6bde130b2687e0c33cf60", - "sha256:040df6fe1a5504eb0f04f048e6d09cd7c7110fef851d7c567a6b6e09942feb7d", - "sha256:068376da5a7e4da51968ce4c122a7cd31afaaec4fccc7856c92f63876e57b51d", - "sha256:06eb1be98df10e81ebaded73fcd51989dcf534e3c753466e4b60c4697a003b67", - "sha256:072623554418a9911446278f16ecb398fb3b540147a7828c06e2011fa531e773", - "sha256:086a27a0b4ca227941700e0b31425e7a28ef1ae8e5e05a33826e17e47fbfdba0", - "sha256:08986dce1339bc932923e7d1232ce9881499a0e02925f7402fb7c982515419ef", - "sha256:0a86e7eeca091c09e021db8eb72d54751e527fa47b8d5787caf96d9831bd02ad", - "sha256:0c32f75920cf99fe6b6c539c399a4a128452eaf1af27f39bce8909c9a3fd8cbe", - "sha256:0d7f453dca13f40a02b79636a339c5b62b670141e63efd511d3f8f73fba162b3", - "sha256:1062b39a0a2b75a9c694f7a08e7183a80c63c0d62b301418ffd9c35f55aaa114", - "sha256:13291b39131e2d002a7940fb176e120bec5145f3aeb7621be6534e46251912c4", - "sha256:149f5008d286636e48cd0b1dd65018548944e495b0265b45e1bffecce1ef7f39", - "sha256:164d8b7b3b4bcb2068b97428060b2a53be050085ef94eca7f240e7947f1b080e", - "sha256:167ed4852351d8a750da48712c3930b031f6efdaa0f22fa1933716bfcd6bf4a3", - "sha256:1c4de13f06a0d54fa0d5ab1b7138bfa0d883220965a29616e3ea61b35d5f5fc7", - "sha256:202eb32e89f60fc147a41e55cb086db2a3f8cb82f9a9a88440dcfc5d37faae8d", - "sha256:220902c3c5cc6af55d4fe19ead504de80eb91f786dc102fbd74894b1551f095e", - "sha256:2b3361af3198667e99927da8b84c1b010752fa4b1115ee30beaa332cabc3ef1a", - "sha256:2c89a8cc122b25ce6945f0423dc1352cb9593c68abd19223eebbd4e56612c5b7", - "sha256:2d548dafee61f06ebdb584080621f3e0c23fff312f0de1afc776e2a2ba99a74f", - "sha256:2e34b51b650b23ed3354b5a07aab37034d9f923db2a40519139af34f485f77d0", - "sha256:32f9a4c643baad4efa81d549c2aadefaeba12249b2adc5af541759237eee1c54", - "sha256:3a51ccc315653ba012774efca4f23d1d2a8a8f278a6072e29c7147eee7da446b", - "sha256:3cde6e9f2580eb1665965ce9bf17ff4952f34f5b126beb509fee8f4e994f143c", - "sha256:40291b1b89ca6ad8d3f2b82782cc33807f1406cf68c8d440861da6304d8ffbbd", - "sha256:41758407fc32d5c3c5de163888068cfee69cb4c2be844e7ac517a52770f9af57", - "sha256:4181b814e56078e9b00427ca358ec44333765f5ca1b45597ec7446d3a1ef6e34", - "sha256:4f51f88c126370dcec4908576c5a627220da6c09d0bff31cfa89f2523843316d", - "sha256:50153825ee016b91549962f970d6a4442fa106832e14c918acd1c8e479916c4f", - "sha256:5056b185ca113c88e18223183aa1a50e66507769c9640a6ff75859619d73957b", - "sha256:5071b2093e793357c9d8b2929dfc13ac5f0a6c650559503bb81189d0a3814519", - "sha256:525eab0b789891ac3be914d36893bdf972d483fe66551f79d3e27146191a37d4", - "sha256:52fb28f528778f184f870b7cf8f225f5eef0a8f6e3778529bdd40c7b3920796a", - "sha256:5478c6962ad548b54a591778e93cd7c456a7a29f8eca9c49e4f9a806dcc5d638", - "sha256:5670bce7b200273eee1840ef307bfa07cda90b38ae56e9a6ebcc9f50da9c469b", - "sha256:5704e174f8ccab2026bd2f1ab6c510345ae8eac818b613d7d73e785f1310f839", - "sha256:59dfe1ed21aea057a65c6b586afd2a945de04fc7db3de0a6e3ed5397ad491b07", - "sha256:5e7e351589da0850c125f1600a4c4ba3c722efefe16b297de54300f08d734fbf", - "sha256:63b13cfd72e9601125027202cad74995ab26921d8cd935c25f09c630436348ff", - "sha256:658f90550f38270639e83ce492f27d2c8d2cd63805c65a13a14d36ca126753f0", - "sha256:684d7a212682996d21ca12ef3c17353c021fe9de6049e19ac8481ec35574a70f", - "sha256:69ab78f848845569401469da20df3e081e6b5a11cb086de3eed1d48f5ed57c95", - "sha256:6f44ec28b1f858c98d3036ad5d7d0bfc568bdd7a74f9c24e25f41ef1ebfd81a4", - "sha256:70b7fa6606c2881c1db9479b0eaa11ed5dfa11c8d60a474ff0e095099f39d98e", - "sha256:764e71f22ab3b305e7f4c21f1a97e1526a25ebdd22513e251cf376760213da13", - "sha256:7ab159b063c52a0333c884e4679f8d7a85112ee3078fe3d9004b2dd875585519", - "sha256:805e6b60c54bf766b251e94526ebad60b7de0c70f70a4e6210ee2891acb70bf2", - "sha256:8447d2d39b5abe381419319f942de20b7ecd60ce86f16a23b0698f22e1b70008", - "sha256:86fddba590aad9208e2fa8b43b4c098bb0ec74f15718bb6a704e3c63e2cef3e9", - "sha256:89d75e7293d2b3e674db7d4d9b1bee7f8f3d1609428e293771d1a962617150cc", - "sha256:93c0b12d3d3bc25af4ebbf38f9ee780a487e8bf6954c115b9f015822d3bb8e48", - "sha256:94d87b689cdd831934fa3ce16cc15cd65748e6d689f5d2b8f4f4df2065c9fa20", - "sha256:9714398225f299aa85267fd222f7142fcb5c769e73d7733344efc46f2ef5cf89", - "sha256:982e6d21414e78e1f51cf595d7f321dcd14de1f2881c5dc6a6e23bbbbd68435e", - "sha256:997d6a487ff00807ba810e0f8332c18b4eb8d29463cfb7c820dc4b6e7562d0cf", - "sha256:a03e02f48cd1abbd9f3b7e3586d97c8f7a9721c436f51a5245b3b9483044480b", - "sha256:a36fdf2af13c2b14738f6e973aba563623cb77d753bbbd8d414d18bfaa3105dd", - "sha256:a6ba92c0bcdf96cbf43a12c717eae4bc98325ca3730f6b130ffa2e3c3c723d84", - "sha256:a7c2155f790e2fb448faed6dd241386719802296ec588a8b9051c1f5c481bc29", - "sha256:a93c194e2df18f7d264092dc8539b8ffb86b45b899ab976aa15d48214138e81b", - "sha256:abfa5080c374a76a251ba60683242bc17eeb2c9818d0d30117b4486be10c59d3", - "sha256:ac10f2c4184420d881a3475fb2c6f4d95d53a8d50209a2500723d831036f7c45", - "sha256:ad182d02e40de7459b73155deb8996bbd8e96852267879396fb274e8700190e3", - "sha256:b2837718570f95dd41675328e111345f9b7095d821bac435aac173ac80b19983", - "sha256:b489578720afb782f6ccf2840920f3a32e31ba28a4b162e13900c3e6bd3f930e", - "sha256:b583904576650166b3d920d2bcce13971f6f9e9a396c673187f49811b2769dc7", - "sha256:b85c2530be953a890eaffde05485238f07029600e8f098cdf1848d414a8b45e4", - "sha256:b97c1e0bd37c5cd7902e65f410779d39eeda155800b65fc4d04cc432efa9bc6e", - "sha256:ba9b72e5643641b7d41fa1f6d5abda2c9a263ae835b917348fc3c928182ad467", - "sha256:bb26437975da7dc36b7efad18aa9dd4ea569d2357ae6b783bf1118dabd9ea577", - "sha256:bb8f74f2f10dbf13a0be8de623ba4f9491faf58c24064f32b65679b021ed0001", - "sha256:bde01f35767c4a7899b7eb6e823b125a64de314a8ee9791367c9a34d56af18d0", - "sha256:bec9931dfb61ddd8ef2ebc05646293812cb6b16b60cf7c9511a832b6f1854b55", - "sha256:c36f9b6f5f8649bb251a5f3f66564438977b7ef8386a52460ae77e6070d309d9", - "sha256:cdf58d0e516ee426a48f7b2c03a332a4114420716d55769ff7108c37a09951bf", - "sha256:d1cee317bfc014c2419a76bcc87f071405e3966da434e03e13beb45f8aced1a6", - "sha256:d22326fcdef5e08c154280b71163ced384b428343ae16a5ab2b3354aed12436e", - "sha256:d3660c82f209655a06b587d55e723f0b813d3a7db2e32e5e7dc64ac2a9e86fde", - "sha256:da8f5fc57d1933de22a9e23eec290a0d8a5927a5370d24bda9a6abe50683fe62", - "sha256:df951c5f4a1b1910f1a99ff42c473ff60f8225baa1cdd3539fe2819d9543e9df", - "sha256:e5364a4502efca094731680e80009632ad6624084aff9a23ce8c8c6820de3e51", - "sha256:ea1bfda2f7162605f6e8178223576856b3d791109f15ea99a9f95c16a7636fb5", - "sha256:f02f93b92358ee3f78660e43b4b0091229260c5d5c408d17d60bf26b6c900e86", - "sha256:f056bf21105c2515c32372bbc057f43eb02aae2fda61052e2f7622c801f0b4e2", - "sha256:f1ac758ef6aebfc8943560194e9fd0fa18bcb34d89fd8bd2af18183afd8da3a2", - "sha256:f2a19f302cd1ce5dd01a9099aaa19cae6173306d1302a43b627f62e21cf18ac0", - "sha256:f654882311409afb1d780b940234208a252322c24a93b442ca714d119e68086c", - "sha256:f65557897fc977a44ab205ea871b690adaef6b9da6afda4790a2484b04293a5f", - "sha256:f9d1e379028e0fc2ae3654bac3cbbef81bf3fd571272a42d56c24007979bafb6", - "sha256:fdabbfc59f2c6edba2a6622c647b716e34e8e3867e0ab975412c5c2f79b82da2", - "sha256:fdd6028445d2460f33136c55eeb1f601ab06d74cb3347132e1c24250187500d9", - "sha256:ff590880083d60acc0433f9c3f713c51f7ac6ebb9adf889c79a261ecf541aa91" - ], - "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==2024.11.6" - }, - "simple-websocket": { - "hashes": [ - "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", - "sha256:7939234e7aa067c534abdab3a9ed933ec9ce4691b0713c78acb195560aa52ae4" - ], - "markers": "python_version >= '3.6'", - "version": "==1.1.0" - }, - "six": { - "hashes": [ - "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", - "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.17.0" - }, - "sniffio": { - "hashes": [ - "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", - "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc" - ], - "markers": "python_version >= '3.7'", - "version": "==1.3.1" - }, - "typing-extensions": { - "hashes": [ - "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", - "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af" - ], - "markers": "python_version < '3.13'", - "version": "==4.14.0" - }, - "tzlocal": { - "hashes": [ - "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", - "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d" - ], - "markers": "python_version >= '3.9'", - "version": "==5.3.1" - }, - "wsproto": { - "hashes": [ - "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065", - "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736" - ], - "markers": "python_full_version >= '3.7.0'", - "version": "==1.2.0" - }, - "yarl": { - "hashes": [ - "sha256:04d8cfb12714158abf2618f792c77bc5c3d8c5f37353e79509608be4f18705c9", - "sha256:04d9c7a1dc0a26efb33e1acb56c8849bd57a693b85f44774356c92d610369efa", - "sha256:06d06c9d5b5bc3eb56542ceeba6658d31f54cf401e8468512447834856fb0e61", - "sha256:077989b09ffd2f48fb2d8f6a86c5fef02f63ffe6b1dd4824c76de7bb01e4f2e2", - "sha256:083ce0393ea173cd37834eb84df15b6853b555d20c52703e21fbababa8c129d2", - "sha256:087e9731884621b162a3e06dc0d2d626e1542a617f65ba7cc7aeab279d55ad33", - "sha256:0a6a1e6ae21cdd84011c24c78d7a126425148b24d437b5702328e4ba640a8902", - "sha256:0acfaf1da020253f3533526e8b7dd212838fdc4109959a2c53cafc6db611bff2", - "sha256:119bca25e63a7725b0c9d20ac67ca6d98fa40e5a894bd5d4686010ff73397914", - "sha256:123393db7420e71d6ce40d24885a9e65eb1edefc7a5228db2d62bcab3386a5c0", - "sha256:18e321617de4ab170226cd15006a565d0fa0d908f11f724a2c9142d6b2812ab0", - "sha256:1a06701b647c9939d7019acdfa7ebbfbb78ba6aa05985bb195ad716ea759a569", - "sha256:2137810a20b933b1b1b7e5cf06a64c3ed3b4747b0e5d79c9447c00db0e2f752f", - "sha256:25b3bc0763a7aca16a0f1b5e8ef0f23829df11fb539a1b70476dcab28bd83da7", - "sha256:27359776bc359ee6eaefe40cb19060238f31228799e43ebd3884e9c589e63b20", - "sha256:2a8f64df8ed5d04c51260dbae3cc82e5649834eebea9eadfd829837b8093eb00", - "sha256:33bb660b390a0554d41f8ebec5cd4475502d84104b27e9b42f5321c5192bfcd1", - "sha256:35d20fb919546995f1d8c9e41f485febd266f60e55383090010f272aca93edcc", - "sha256:3b2992fe29002fd0d4cbaea9428b09af9b8686a9024c840b8a2b8f4ea4abc16f", - "sha256:3b4e88d6c3c8672f45a30867817e4537df1bbc6f882a91581faf1f6d9f0f1b5a", - "sha256:3b60a86551669c23dc5445010534d2c5d8a4e012163218fc9114e857c0586fdd", - "sha256:3d7dbbe44b443b0c4aa0971cb07dcb2c2060e4a9bf8d1301140a33a93c98e18c", - "sha256:3e429857e341d5e8e15806118e0294f8073ba9c4580637e59ab7b238afca836f", - "sha256:40ed574b4df723583a26c04b298b283ff171bcc387bc34c2683235e2487a65a5", - "sha256:42fbe577272c203528d402eec8bf4b2d14fd49ecfec92272334270b850e9cd7d", - "sha256:4345f58719825bba29895011e8e3b545e6e00257abb984f9f27fe923afca2501", - "sha256:447c5eadd750db8389804030d15f43d30435ed47af1313303ed82a62388176d3", - "sha256:44869ee8538208fe5d9342ed62c11cc6a7a1af1b3d0bb79bb795101b6e77f6e0", - "sha256:484e7a08f72683c0f160270566b4395ea5412b4359772b98659921411d32ad26", - "sha256:4a34c52ed158f89876cba9c600b2c964dfc1ca52ba7b3ab6deb722d1d8be6df2", - "sha256:4ba5e59f14bfe8d261a654278a0f6364feef64a794bd456a8c9e823071e5061c", - "sha256:4c43030e4b0af775a85be1fa0433119b1565673266a70bf87ef68a9d5ba3174c", - "sha256:4c903e0b42aab48abfbac668b5a9d7b6938e721a6341751331bcd7553de2dcae", - "sha256:4d9949eaf05b4d30e93e4034a7790634bbb41b8be2d07edd26754f2e38e491de", - "sha256:4f1a350a652bbbe12f666109fbddfdf049b3ff43696d18c9ab1531fbba1c977a", - "sha256:53b2da3a6ca0a541c1ae799c349788d480e5144cac47dba0266c7cb6c76151fe", - "sha256:54ac15a8b60382b2bcefd9a289ee26dc0920cf59b05368c9b2b72450751c6eb8", - "sha256:5d0fe6af927a47a230f31e6004621fd0959eaa915fc62acfafa67ff7229a3124", - "sha256:5d3d6d14754aefc7a458261027a562f024d4f6b8a798adb472277f675857b1eb", - "sha256:5d9b980d7234614bc4674468ab173ed77d678349c860c3af83b1fffb6a837ddc", - "sha256:634b7ba6b4a85cf67e9df7c13a7fb2e44fa37b5d34501038d174a63eaac25ee2", - "sha256:65a4053580fe88a63e8e4056b427224cd01edfb5f951498bfefca4052f0ce0ac", - "sha256:686d51e51ee5dfe62dec86e4866ee0e9ed66df700d55c828a615640adc885307", - "sha256:69df35468b66c1a6e6556248e6443ef0ec5f11a7a4428cf1f6281f1879220f58", - "sha256:6d12b8945250d80c67688602c891237994d203d42427cb14e36d1a732eda480e", - "sha256:6d409e321e4addf7d97ee84162538c7258e53792eb7c6defd0c33647d754172e", - "sha256:70e0c580a0292c7414a1cead1e076c9786f685c1fc4757573d2967689b370e62", - "sha256:737e9f171e5a07031cbee5e9180f6ce21a6c599b9d4b2c24d35df20a52fabf4b", - "sha256:7595498d085becc8fb9203aa314b136ab0516c7abd97e7d74f7bb4eb95042abe", - "sha256:798a5074e656f06b9fad1a162be5a32da45237ce19d07884d0b67a0aa9d5fdda", - "sha256:7dc63ad0d541c38b6ae2255aaa794434293964677d5c1ec5d0116b0e308031f5", - "sha256:839de4c574169b6598d47ad61534e6981979ca2c820ccb77bf70f4311dd2cc64", - "sha256:84aeb556cb06c00652dbf87c17838eb6d92cfd317799a8092cee0e570ee11229", - "sha256:85a231fa250dfa3308f3c7896cc007a47bc76e9e8e8595c20b7426cac4884c62", - "sha256:866349da9d8c5290cfefb7fcc47721e94de3f315433613e01b435473be63daa6", - "sha256:8681700f4e4df891eafa4f69a439a6e7d480d64e52bf460918f58e443bd3da7d", - "sha256:86de313371ec04dd2531f30bc41a5a1a96f25a02823558ee0f2af0beaa7ca791", - "sha256:8a7f62f5dc70a6c763bec9ebf922be52aa22863d9496a9a30124d65b489ea672", - "sha256:8c12cd754d9dbd14204c328915e23b0c361b88f3cffd124129955e60a4fbfcfb", - "sha256:8d8a3d54a090e0fff5837cd3cc305dd8a07d3435a088ddb1f65e33b322f66a94", - "sha256:91bc450c80a2e9685b10e34e41aef3d44ddf99b3a498717938926d05ca493f6a", - "sha256:95b50910e496567434cb77a577493c26bce0f31c8a305135f3bda6a2483b8e10", - "sha256:95fc9876f917cac7f757df80a5dda9de59d423568460fe75d128c813b9af558e", - "sha256:9c2aa4387de4bc3a5fe158080757748d16567119bef215bec643716b4fbf53f9", - "sha256:9c366b254082d21cc4f08f522ac201d0d83a8b8447ab562732931d31d80eb2a5", - "sha256:a0bc5e05f457b7c1994cc29e83b58f540b76234ba6b9648a4971ddc7f6aa52da", - "sha256:a884b8974729e3899d9287df46f015ce53f7282d8d3340fa0ed57536b440621c", - "sha256:ab47acc9332f3de1b39e9b702d9c916af7f02656b2a86a474d9db4e53ef8fd7a", - "sha256:af4baa8a445977831cbaa91a9a84cc09debb10bc8391f128da2f7bd070fc351d", - "sha256:af5607159085dcdb055d5678fc2d34949bd75ae6ea6b4381e784bbab1c3aa195", - "sha256:b2586e36dc070fc8fad6270f93242124df68b379c3a251af534030a4a33ef594", - "sha256:b4230ac0b97ec5eeb91d96b324d66060a43fd0d2a9b603e3327ed65f084e41f8", - "sha256:b594113a301ad537766b4e16a5a6750fcbb1497dcc1bc8a4daae889e6402a634", - "sha256:b6c4c3d0d6a0ae9b281e492b1465c72de433b782e6b5001c8e7249e085b69051", - "sha256:b7fa0cb9fd27ffb1211cde944b41f5c67ab1c13a13ebafe470b1e206b8459da8", - "sha256:b9ae2fbe54d859b3ade40290f60fe40e7f969d83d482e84d2c31b9bff03e359e", - "sha256:bb769ae5760cd1c6a712135ee7915f9d43f11d9ef769cb3f75a23e398a92d384", - "sha256:bc906b636239631d42eb8a07df8359905da02704a868983265603887ed68c076", - "sha256:bdb77efde644d6f1ad27be8a5d67c10b7f769804fff7a966ccb1da5a4de4b656", - "sha256:bf099e2432131093cc611623e0b0bcc399b8cddd9a91eded8bfb50402ec35018", - "sha256:c27d98f4e5c4060582f44e58309c1e55134880558f1add7a87c1bc36ecfade19", - "sha256:c8703517b924463994c344dcdf99a2d5ce9eca2b6882bb640aa555fb5efc706a", - "sha256:c9471ca18e6aeb0e03276b5e9b27b14a54c052d370a9c0c04a68cefbd1455eb4", - "sha256:ce360ae48a5e9961d0c730cf891d40698a82804e85f6e74658fb175207a77cb2", - "sha256:d0bf955b96ea44ad914bc792c26a0edcd71b4668b93cbcd60f5b0aeaaed06c64", - "sha256:d2cbca6760a541189cf87ee54ff891e1d9ea6406079c66341008f7ef6ab61145", - "sha256:d4fad6e5189c847820288286732075f213eabf81be4d08d6cc309912e62be5b7", - "sha256:d88cc43e923f324203f6ec14434fa33b85c06d18d59c167a0637164863b8e995", - "sha256:db243357c6c2bf3cd7e17080034ade668d54ce304d820c2a58514a4e51d0cfd6", - "sha256:dd59c9dd58ae16eaa0f48c3d0cbe6be8ab4dc7247c3ff7db678edecbaf59327f", - "sha256:e06b9f6cdd772f9b665e5ba8161968e11e403774114420737f7884b5bd7bdf6f", - "sha256:e52d6ed9ea8fd3abf4031325dc714aed5afcbfa19ee4a89898d663c9976eb487", - "sha256:ea52f7328a36960ba3231c6677380fa67811b414798a6e071c7085c57b6d20a9", - "sha256:eaddd7804d8e77d67c28d154ae5fab203163bd0998769569861258e525039d2a", - "sha256:f0cf05ae2d3d87a8c9022f3885ac6dea2b751aefd66a4f200e408a61ae9b7f0d", - "sha256:f106e75c454288472dbe615accef8248c686958c2e7dd3b8d8ee2669770d020f", - "sha256:f166eafa78810ddb383e930d62e623d288fb04ec566d1b4790099ae0f31485f1", - "sha256:f1f6670b9ae3daedb325fa55fbe31c22c8228f6e0b513772c2e1c623caa6ab22", - "sha256:f4d3fa9b9f013f7050326e165c3279e22850d02ae544ace285674cb6174b5d6d", - "sha256:f8d8aa8dd89ffb9a831fedbcb27d00ffd9f4842107d52dc9d57e64cb34073d5c", - "sha256:f9d02b591a64e4e6ca18c5e3d925f11b559c763b950184a64cf47d74d7e41877", - "sha256:faa709b66ae0e24c8e5134033187a972d849d87ed0a12a0366bedcc6b5dc14a5", - "sha256:fb0caeac4a164aadce342f1597297ec0ce261ec4532bbc5a9ca8da5622f53867", - "sha256:fdb5204d17cb32b2de2d1e21c7461cabfacf17f3645e4b9039f210c5d3378bf3" - ], - "markers": "python_version >= '3.9'", - "version": "==1.20.0" - }, - "yt-dlp": { - "hashes": [ - "sha256:a49c4b76afeaded6254c3e2b759d8d5a13271aa963d5fccb51fe059d1c313151", - "sha256:ea73854c5dabc124f29a35a8fae9bc5d422ef3231bebeea2bdfa82ac191a9c29" - ], - "index": "pypi", - "markers": "python_version >= '3.9'", - "version": "==2025.5.22" - } - }, - "develop": {} -} diff --git a/app.spec b/app.spec new file mode 100644 index 00000000..cc503913 --- /dev/null +++ b/app.spec @@ -0,0 +1,68 @@ +import os +import platform +import sys +import tomllib + +block_cipher = None + +machine = platform.machine().lower() +binaries = [] +if sys.platform.startswith("linux"): + if machine in ("x86_64", "amd64"): + libdir = "/usr/lib/x86_64-linux-gnu" + elif machine in ("aarch64", "arm64"): + libdir = "/usr/lib/aarch64-linux-gnu" + else: + libdir = "/usr/lib" + binaries = [] +elif sys.platform == "darwin": + binaries = [] +elif sys.platform.startswith("win"): + # Windows DLLs + binaries = [] + +with open("./uv.lock", "rb") as f: + lock = tomllib.load(f) + +hidden = [ + *lock.get("dependencies", {}).keys(), + "aiohttp", + "socketio", + "engineio", + "engineio.async_drivers.aiohttp", + "socketio.async_drivers.aiohttp", +] + +hidden = [f.replace("-", "_") for f in hidden] + +a = Analysis( # noqa: F821 # type: ignore + ["app/native.py"], + pathex=[os.getcwd()], + binaries=binaries, + datas=[ + ("ui/exported", "ui/exported"), + ("app/migrations", "migrations"), + ("app/library/presets.json", "library"), + ], + hiddenimports=hidden, + hookspath=[], + runtime_hooks=[], + excludes=[], + cipher=block_cipher, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) # type: ignore # noqa: F821 + +exe = EXE( # type: ignore # noqa: F821 + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + name="YTPTube", + debug=False, + strip=False, + upx=True, + console=False, + onefile=True, +) diff --git a/app/library/DataStore.py b/app/library/DataStore.py index eb92e4a5..cce7766a 100644 --- a/app/library/DataStore.py +++ b/app/library/DataStore.py @@ -1,5 +1,6 @@ import copy import json +import logging from collections import OrderedDict from datetime import UTC, datetime from email.utils import formatdate @@ -8,7 +9,9 @@ from sqlite3 import Connection from .config import Config from .Download import Download from .ItemDTO import ItemDTO -from .Utils import clean_item +from .Utils import init_class + +LOG = logging.getLogger("datastore") class DataStore: @@ -69,9 +72,9 @@ class DataStore: for row in cursor: rowDate = datetime.strptime(row["created_at"], "%Y-%m-%d %H:%M:%S") # noqa: DTZ007 - data, _ = clean_item(json.loads(row["data"]), keys=ItemDTO.removed_fields()) + data: dict = json.loads(row["data"]) data.pop("_id", None) - item: ItemDTO = ItemDTO(**data) + item: ItemDTO = init_class(ItemDTO, data) item._id = row["id"] item.datetime = formatdate(rowDate.replace(tzinfo=UTC).timestamp()) items.append((row["id"], item)) @@ -147,10 +150,4 @@ class DataStore: ) def _delete_store_item(self, key: str) -> None: - self.connection.execute( - 'DELETE FROM "history" WHERE "type" = ? AND "id" = ?', - ( - self.type, - key, - ), - ) + self.connection.execute('DELETE FROM "history" WHERE "type" = ? AND "id" = ?', (self.type, key)) diff --git a/app/library/Download.py b/app/library/Download.py index 7bfaf4b4..92c0e0d5 100644 --- a/app/library/Download.py +++ b/app/library/Download.py @@ -4,10 +4,10 @@ import logging import multiprocessing import os import re -import shutil import time from datetime import UTC, datetime from email.utils import formatdate +from pathlib import Path import yt_dlp @@ -16,7 +16,7 @@ from .config import Config from .Events import EventBus, Events from .ffprobe import ffprobe from .ItemDTO import ItemDTO -from .Utils import extract_info, extract_ytdlp_logs, load_cookies +from .Utils import delete_dir, extract_info, extract_ytdlp_logs, load_cookies from .YTDLPOpts import YTDLPOpts @@ -137,7 +137,7 @@ class Download: return if "__finaldir" in data["info_dict"]: - filename = os.path.join(data["info_dict"]["__finaldir"], os.path.basename(data["info_dict"]["filepath"])) + filename = str(Path(data["info_dict"]["__finaldir"]) / Path(data["info_dict"]["filepath"]).name) else: filename = data["info_dict"]["filepath"] @@ -151,17 +151,17 @@ class Download: def _download(self): try: - params = ( + params: dict = ( YTDLPOpts.get_instance() - .preset(self.preset, with_cookies=not self.info.cookies) + .preset(self.preset) .add({"break_on_existing": True}) .add_cli(args=self.info.cli, from_user=True) .add( config={ "color": "no_color", "paths": { - "home": self.download_dir, - "temp": self.temp_path, + "home": str(self.download_dir), + "temp": str(self.temp_path), }, "outtmpl": { "default": self.template, @@ -189,13 +189,12 @@ class Download: if self.info.cookies: try: - cookie_file = os.path.join(self.temp_path, f"cookie_{self.info._id}.txt") + cookie_file = Path(self.temp_path) / f"cookie_{self.info._id}.txt" self.logger.debug( f"Creating cookie file for '{self.info.id}: {self.info.title}' - '{cookie_file}'." ) - with open(cookie_file, "w") as f: - f.write(self.info.cookies) - params["cookiefile"] = f.name + cookie_file.write_text(self.info.cookies) + params["cookiefile"] = str(cookie_file.as_posix()) load_cookies(cookie_file) except Exception as e: @@ -280,10 +279,12 @@ class Download: self.status_queue = Config.get_manager().Queue() # Create temp dir for each download. - self.temp_path = os.path.join(self.temp_dir, hashlib.shake_256(f"D-{self.info.id}".encode()).hexdigest(5)) + self.temp_path = Path(self.temp_dir) / hashlib.shake_256(f"D-{self.info.id}".encode()).hexdigest(5) - if not os.path.exists(self.temp_path): - os.makedirs(self.temp_path, exist_ok=True) + if not self.temp_path.exists(): + self.temp_path.mkdir(parents=True, exist_ok=True) + + self.info.temp_path = str(self.temp_path) self.proc = multiprocessing.Process(name=f"download-{self.id}", target=self._download) self.proc.start() @@ -385,17 +386,19 @@ class Download: ) return - if not os.path.exists(self.temp_path): + tmp_dir = Path(self.temp_path) + + if not tmp_dir.exists(): return - if self.temp_path == self.temp_dir: + if str(tmp_dir) == str(self.temp_dir): self.logger.warning( - f"Attempted to delete video temp folder: {self.temp_path}, but it is the same as main temp folder." + f"Attempted to delete video temp folder '{self.temp_path}', but it is the same as main temp folder." ) return - self.logger.info(f"Deleting Temp folder '{self.temp_path}'.") - shutil.rmtree(self.temp_path, ignore_errors=True) + status = delete_dir(tmp_dir) + self.logger.info(f"Temp folder '{self.temp_path}' deletion is {'success' if status else 'failed'}.") async def progress_update(self): """ @@ -424,11 +427,15 @@ class Download: self.tmpfilename = status.get("tmpfilename") if "filename" in status: - self.info.filename = os.path.relpath(status.get("filename"), self.download_dir) + fl = Path(status.get("filename")) + try: + self.info.filename = str(Path(status.get("filename")).relative_to(Path(self.download_dir))) + except ValueError: + self.info.filename = str(Path(status.get("filename")).relative_to(Path(self.temp_path))) - if os.path.exists(status.get("filename")): + if fl.is_file() and fl.exists(): try: - self.info.file_size = os.path.getsize(status.get("filename")) + self.info.file_size = fl.stat().st_size except FileNotFoundError: self.info.file_size = 0 @@ -439,7 +446,8 @@ class Download: self.info.error = status.get("error") await self._notify.emit(Events.ERROR, data={"message": self.info.error, "data": self.info}) - if "downloaded_bytes" in status: + if "downloaded_bytes" in status and status.get("downloaded_bytes") > 0: + self.info.downloaded_bytes = status.get("downloaded_bytes") total = status.get("total_bytes") or status.get("total_bytes_estimate") if total: try: @@ -451,17 +459,11 @@ class Download: self.info.speed = status.get("speed") self.info.eta = status.get("eta") - if ( - "finished" == self.info.status - and "filename" in status - and os.path.isfile(status.get("filename")) - and os.path.exists(status.get("filename")) - ): - try: - self.info.file_size = os.path.getsize(status.get("filename")) - self.info.datetime = str(formatdate(time.time())) - except FileNotFoundError: - pass + fl = Path(status.get("filename")) if status and "filename" in status else None + + if "finished" == self.info.status and fl and fl.is_file() and fl.exists(): + self.info.file_size = fl.stat().st_size + self.info.datetime = str(formatdate(time.time())) try: ff = await ffprobe(status.get("filename")) @@ -471,7 +473,7 @@ class Download: self.info.extras["is_video"] = True self.info.extras["is_audio"] = True self.logger.exception(e) - self.logger.error(f"Failed to ffprobe: {status.get}. {e}") + self.logger.error(f"Failed to run ffprobe. {status.get}. {e}") await self._notify.emit(Events.UPDATED, data=self.info) diff --git a/app/library/DownloadQueue.py b/app/library/DownloadQueue.py index d9fc6b1e..5c3ec100 100644 --- a/app/library/DownloadQueue.py +++ b/app/library/DownloadQueue.py @@ -2,7 +2,6 @@ import asyncio import functools import glob import logging -import os import time import uuid from datetime import UTC, datetime, timedelta @@ -10,7 +9,6 @@ from email.utils import formatdate, parsedate_to_datetime from pathlib import Path from sqlite3 import Connection -import anyio import yt_dlp from aiohttp import web @@ -399,7 +397,7 @@ class DownloadQueue(metaclass=Singleton): item.template = _preset.template yt_conf = {} - cookie_file = os.path.join(self.config.temp_path, f"c_{uuid.uuid4().hex}.txt") + cookie_file = Path(self.config.temp_path) / f"c_{uuid.uuid4().hex}.txt" LOG.info(f"Adding '{item.__repr__()}'.") @@ -420,10 +418,7 @@ class DownloadQueue(metaclass=Singleton): "level": logging.WARNING, "name": "callback-logger", }, - **YTDLPOpts.get_instance() - .preset(name=item.preset, with_cookies=not item.cookies) - .add_cli(args=item.cli, from_user=True) - .get_all(), + **YTDLPOpts.get_instance().preset(name=item.preset).add_cli(args=item.cli, from_user=True).get_all(), } if yt_conf.get("external_downloader"): @@ -441,10 +436,8 @@ class DownloadQueue(metaclass=Singleton): if item.cookies: try: - async with await anyio.open_file(cookie_file, "w") as f: - await f.write(item.cookies) - yt_conf["cookiefile"] = f.name - + cookie_file.write_text(item.cookies) + yt_conf["cookiefile"] = str(cookie_file.as_posix()) load_cookies(cookie_file) except Exception as e: msg = f"Failed to create cookie file for '{item.url}'. '{e!s}'." @@ -493,10 +486,10 @@ class DownloadQueue(metaclass=Singleton): "msg": f"TimeoutError: {self.config.extract_info_timeout}s reached Unable to extract info.", } finally: - if cookie_file and os.path.exists(cookie_file): + if cookie_file and cookie_file.exists(): try: - os.remove(cookie_file) - del yt_conf["cookiefile"] + cookie_file.unlink(missing_ok=True) + yt_conf.pop("cookiefile", None) except Exception as e: LOG.error(f"Failed to remove cookie file '{yt_conf['cookiefile']}'. {e!s}") @@ -562,7 +555,7 @@ class DownloadQueue(metaclass=Singleton): for id in ids: try: - item = self.done.get(key=id) + item: Download = self.done.get(key=id) except KeyError as e: status[id] = str(e) status["status"] = "error" @@ -573,24 +566,34 @@ class DownloadQueue(metaclass=Singleton): removed_files = 0 filename: str = "" + LOG.info( + f"{remove_file=} {itemRef} - Removing local files: {self.config.remove_files}, {item.info.status=}" + ) + if remove_file and self.config.remove_files and "finished" == item.info.status: filename = str(item.info.filename) if item.info.folder: filename = f"{item.info.folder}/{item.info.filename}" try: - realFile: str = calc_download_path( - base_path=self.config.download_path, - folder=filename, - create_path=False, + rf = Path( + calc_download_path( + base_path=Path(self.config.download_path), + folder=filename, + create_path=False, + ) ) - rf = Path(realFile) if rf.is_file() and rf.exists(): - for f in rf.parent.glob(f"{glob.escape(rf.stem)}.*"): - if f.is_file() and f.exists() and not f.name.startswith("."): - removed_files += 1 - LOG.debug(f"Removing '{itemRef}' local file '{f.name}'.") - os.remove(f) + if rf.stem and rf.suffix: + for f in rf.parent.glob(f"{glob.escape(rf.stem)}.*"): + if f.is_file() and f.exists() and not f.name.startswith("."): + removed_files += 1 + LOG.debug(f"Removing '{itemRef}' local file '{f.name}'.") + f.unlink(missing_ok=True) + else: + LOG.debug(f"Removing '{itemRef}' local file '{rf.name}'.") + rf.unlink(missing_ok=True) + removed_files += 1 else: LOG.warning(f"Failed to remove '{itemRef}' local file '{filename}'. File not found.") except Exception as e: @@ -701,22 +704,13 @@ class DownloadQueue(metaclass=Singleton): """ filePath = calc_download_path(base_path=self.config.download_path, folder=entry.info.folder) - LOG.info( - f"Downloading 'id: {id}', 'Title: {entry.info.title}', 'URL: {entry.info.url}' to 'Folder: {filePath}'." - ) + LOG.info(f"Downloading 'id: {id}', 'Title: {entry.info.title}', 'URL: {entry.info.url}' To '{filePath}'.") try: self._active[entry.info._id] = entry await entry.start() if "finished" != entry.info.status: - if entry.tmpfilename and os.path.isfile(entry.tmpfilename): - try: - os.remove(entry.tmpfilename) - entry.tmpfilename = None - except Exception: - pass - entry.info.status = "error" finally: if entry.info._id in self._active: diff --git a/app/library/HttpAPI.py b/app/library/HttpAPI.py index 4fcc8b41..d11b693a 100644 --- a/app/library/HttpAPI.py +++ b/app/library/HttpAPI.py @@ -4,7 +4,6 @@ import functools import hmac import json import logging -import os import random import time import uuid @@ -18,6 +17,7 @@ import httpx import magic from aiohttp import web from aiohttp.web import Request, RequestHandler, Response +from library.ag_utils import ag from yt_dlp.cookies import LenientSimpleCookie from .cache import Cache @@ -47,6 +47,7 @@ from .Utils import ( get_file_sidecar, get_files, get_mime_type, + init_class, read_logfile, validate_url, validate_uuid, @@ -85,6 +86,7 @@ class HttpAPI(Common): def __init__( self, + root_path: str, queue: DownloadQueue | None = None, encoder: Encoder | None = None, config: Config | None = None, @@ -94,10 +96,11 @@ class HttpAPI(Common): self.config = config or Config.get_instance() self._notify = EventBus.get_instance() - self.rootPath = str(Path(__file__).parent.parent.parent) + self.rootPath = root_path self.routes = web.RouteTableDef() self.cache = Cache() self.app: web.Application | None = None + self.isRequestingBackground = False super().__init__(queue=self.queue, encoder=self.encoder, config=self.config) @@ -129,7 +132,7 @@ class HttpAPI(Common): return decorator async def on_shutdown(self, _: web.Application): - LOG.debug("Shutting down http API server.") + pass def attach(self, app: web.Application) -> "HttpAPI": """ @@ -225,43 +228,48 @@ class HttpAPI(Common): HttpAPI: The instance of the HttpAPI. """ - staticDir = os.path.join(self.rootPath, "ui", "exported") - if not os.path.exists(staticDir): - msg = f"Could not find the frontend UI static assets. '{staticDir}'." - raise ValueError(msg) + staticDir = (Path(self.rootPath) / "ui" / "exported").absolute() + if not staticDir.exists(): + staticDir = (Path(self.rootPath).parent / "ui" / "exported").absolute() + if not staticDir.exists(): + msg = f"Could not find the frontend UI static assets. '{staticDir}'." + raise ValueError(msg) preloaded = 0 - base_path: str = self.config.base_path.rstrip("/") + base_path: str = str(Path(self.config.base_path).as_posix()).rstrip("/") - for root, _, files in os.walk(staticDir): - for file in files: - if file.endswith(".map"): - continue + for file in staticDir.rglob("*.*"): + if ".map" == file.suffix: + continue - file = os.path.join(root, file) - urlPath: str = f"{base_path}/{file.replace(f'{staticDir}/', '')}" + urlPath: str = f"{base_path}/{str(file.as_posix()).replace(f'{staticDir.as_posix()!s}/', '')}" - with open(file, "rb") as f: - content = f.read() + with open(file, "rb") as f: + content = f.read() - contentType = self._ext_to_mime.get(os.path.splitext(file)[1], MIME.from_file(file)) + contentType = self._ext_to_mime.get(file.suffix, MIME.from_file(file)) - self._static_holder[urlPath] = {"content": content, "content_type": contentType} - LOG.debug(f"Preloading '{urlPath}'.") - app.router.add_get(urlPath, self._static_file) + self._static_holder[urlPath] = {"content": content, "content_type": contentType} + LOG.debug(f"Preloading static '{urlPath}'.") + app.router.add_get(urlPath, self._static_file) + preloaded += 1 + + if "/index.html" in self._static_holder: + for path in self._frontend_routes: + path: str = f"{base_path}/{path.lstrip('/')}" + self._static_holder[path] = self._static_holder["/index.html"] + app.router.add_get(path, self._static_file) + if "{" not in path: + self._static_holder[path + "/"] = self._static_holder["/index.html"] + app.router.add_get(path + "/", self._static_file) + LOG.debug(f"Preloading static route '{path}'.") preloaded += 1 - if urlPath.endswith("/index.html"): - for path in self._frontend_routes: - path: str = f"{base_path}/{path.lstrip('/')}" - self._static_holder[path] = {"content": content, "content_type": contentType} - app.router.add_get(path, self._static_file) - if "{" not in path: - self._static_holder[path + "/"] = {"content": content, "content_type": contentType} - app.router.add_get(path + "/", self._static_file) - LOG.debug(f"Preloading '{path}'.") - preloaded += 1 + if "/" != self.config.base_path: + LOG.debug(f"adding base_path folder '{base_path}' to routes.") + app.router.add_get(base_path, self._static_file, name="_base_path") + app.router.add_get(f"{base_path}/", self._static_file, name="_base_path_slash") if preloaded < 1: message = f"Failed to find any static files in '{staticDir}'." @@ -273,13 +281,6 @@ class HttpAPI(Common): LOG.info(f"Preloaded '{preloaded}' static files.") - if "/" != self.config.base_path: - LOG.debug(f"adding base_path folder '{self.config.base_path}' to routes.") - app.router.add_get(self.config.base_path.rstrip("/"), self._static_file, name="base_path_static") - app.router.add_get( - f"{self.config.base_path.rstrip('/')}/", self._static_file, name="base_path_static_slash" - ) - return self def add_routes(self, app: web.Application) -> "HttpAPI": @@ -308,7 +309,7 @@ class HttpAPI(Common): if hasattr(method, "_http_name") and method._http_name: opts["name"] = method._http_name - LOG.debug(f"Registering route {method._http_method} {base_path}/{http_path}' {opts}.") + LOG.debug(f"Adding API route {method._http_method} {base_path}/{http_path}' {opts}.") self.routes.route(method._http_method, f"{base_path}/{http_path}", **opts)(method) if http_path in registered_options: @@ -597,7 +598,7 @@ class HttpAPI(Common): if ytdlp_proxy := self.config.get_ytdlp_args().get("proxy", None): opts["proxy"] = ytdlp_proxy - ytdlp_opts = YTDLPOpts.get_instance().preset(name=preset, with_cookies=True).add(opts).get_all() + ytdlp_opts = YTDLPOpts.get_instance().preset(name=preset).add(opts).get_all() data = extract_info( config=ytdlp_opts, @@ -657,7 +658,8 @@ class HttpAPI(Common): data={"error": "Manual archive is not enabled."}, status=web.HTTPNotFound.status_code ) - if not os.path.exists(manual_archive): + manual_archive = Path(manual_archive) + if not manual_archive.exists(): return web.json_response( data={"error": "Manual archive file not found.", "file": manual_archive}, status=web.HTTPNotFound.status_code, @@ -705,7 +707,7 @@ class HttpAPI(Common): continue tasks.append( - asyncio.get_event_loop().run_in_executor(None, lambda id=id, url=url: info_wrapper(id=id, url=url)) + asyncio.get_event_loop().run_in_executor(None, lambda i=id, url=url: info_wrapper(id=i, url=url)) ) if len(tasks) > 0: @@ -818,7 +820,7 @@ class HttpAPI(Common): limit = 50 logs_data = await read_logfile( - file=os.path.join(self.config.config_path, "logs", "app.log"), + file=Path(self.config.config_path) / "logs" / "app.log", offset=offset, limit=limit, ) @@ -910,7 +912,7 @@ class HttpAPI(Common): status=web.HTTPBadRequest.status_code, ) - items.append(Condition(**item)) + items.append(init_class(Condition, item)) try: items = cls.save(items=items).load().get_all() except Exception as e: @@ -958,7 +960,7 @@ class HttpAPI(Common): opts = {} if ytdlp_proxy := self.config.get_ytdlp_args().get("proxy", None): opts["proxy"] = ytdlp_proxy - ytdlp_opts = YTDLPOpts.get_instance().preset(name=preset, with_cookies=True).add(opts).get_all() + ytdlp_opts = YTDLPOpts.get_instance().preset(name=preset).add(opts).get_all() data = extract_info( config=ytdlp_opts, @@ -1143,7 +1145,7 @@ class HttpAPI(Common): item["cli"] = "" try: - ins.validate(item) + Tasks.validate(item) except ValueError as e: return web.json_response( {"error": f"Failed to validate task '{item.get('name')}'. '{e!s}'"}, @@ -1258,109 +1260,6 @@ class HttpAPI(Common): return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=self.encoder.encode) - @route("GET", "api/workers", "pool_list") - async def pool_list(self, _) -> Response: - """ - Get the workers status. - - Args: - _: The request object. - - Returns: - Response: The response object. - - """ - if self.queue.pool is None: - return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code) - - status = self.queue.pool.get_workers_status() - - data = [] - - for worker in status: - worker_status = status.get(worker) - data.append( - { - "id": worker, - "data": {"status": "Waiting for download."} if worker_status is None else worker_status, - } - ) - - return web.json_response( - data={ - "open": self.queue.pool.has_open_workers(), - "count": self.queue.pool.get_available_workers(), - "workers": data, - }, - status=web.HTTPOk.status_code, - dumps=lambda obj: json.dumps(obj, default=lambda o: f"<>"), - ) - - @route("POST", "api/workers", "pool_start") - async def pool_restart(self, _) -> Response: - """ - Restart the workers pool. - - Args: - _: The request object. - - Returns: - Response: The response object. - - """ - if self.queue.pool is None: - return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code) - - self.queue.pool.start() - - return web.json_response({"message": "Workers pool being restarted."}, status=web.HTTPOk.status_code) - - @route("PATCH", "api/workers/{id}", "worker_restart") - async def worker_restart(self, request: Request) -> Response: - """ - Restart a worker. - - Args: - request (Request): The request object. - - Returns: - Response: The response object - - """ - id: str = request.match_info.get("id") - if not id: - return web.json_response({"error": "worker id is required."}, status=web.HTTPBadRequest.status_code) - - if self.queue.pool is None: - return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code) - - status = await self.queue.pool.restart(id, "requested by user.") - - return web.json_response({"status": "restarted" if status else "in_error_state"}, status=web.HTTPOk.status_code) - - @route("DELETE", "api/workers/{id}", "worker_stop") - async def worker_stop(self, request: Request) -> Response: - """ - Stop a worker. - - Args: - request (Request): The request object. - - Returns: - Response: The response object. - - """ - id: str = request.match_info.get("id") - if not id: - raise web.HTTPBadRequest(text="worker id is required.") - - if self.queue.pool is None: - return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code) - - status = await self.queue.pool.stop(id, "requested by user.") - - return web.json_response({"status": "stopped" if status else "in_error_state"}, status=web.HTTPOk.status_code) - @route("GET", "api/player/playlist/{file:.*}.m3u8", "playlist") async def playlist(self, request: Request) -> Response: """ @@ -1398,7 +1297,9 @@ class HttpAPI(Common): return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status) return web.Response( - text=await Playlist(download_path=self.config.download_path, url=f"{base_path}/").make(file=realFile), + text=await Playlist(download_path=Path(self.config.download_path), url=f"{base_path}/").make( + file=realFile + ), headers={ "Content-Type": "application/x-mpegURL", "Cache-Control": "no-cache", @@ -1443,7 +1344,7 @@ class HttpAPI(Common): base_path: str = self.config.base_path.rstrip("/") try: - cls = M3u8(download_path=self.config.download_path, url=f"{base_path}/") + cls = M3u8(download_path=Path(self.config.download_path), url=f"{base_path}/") realFile, status = get_file(download_path=self.config.download_path, file=file) if web.HTTPFound.status_code == status: @@ -1596,7 +1497,7 @@ class HttpAPI(Common): return web.Response(status=web.HTTPNotModified.status_code, headers={"Last-Modified": lastMod}) return web.Response( - body=await Subtitle(download_path=self.config.download_path).make(file=realFile), + body=await Subtitle().make(file=realFile), headers={ "Content-Type": "text/vtt; charset=UTF-8", "X-Accel-Buffering": "no", @@ -1707,8 +1608,13 @@ class HttpAPI(Common): """ backend = None + if self.isRequestingBackground: + return web.Response(status=web.HTTPTooManyRequests.status_code) + try: + self.isRequestingBackground = True backend = random.choice(self.config.pictures_backends) # noqa: S311 + CACHE_KEY_BING = "random_background_bing" CACHE_KEY = "random_background" if self.cache.has(CACHE_KEY) and not request.query.get("force", False): @@ -1732,6 +1638,29 @@ class HttpAPI(Common): } async with httpx.AsyncClient(**opts) as client: + if backend.startswith("https://www.bing.com/HPImageArchive.aspx"): + if not self.cache.has(CACHE_KEY_BING): + response = await client.request(method="GET", url=backend) + if response.status_code != web.HTTPOk.status_code: + return web.json_response( + data={"error": "failed to retrieve the random background image."}, + status=web.HTTPInternalServerError.status_code, + ) + + img_url: str | None = ag(response.json(), "images.0.url") + if not img_url: + return web.json_response( + data={"error": "failed to retrieve the random background image."}, + status=web.HTTPInternalServerError.status_code, + ) + + backend = f"https://www.bing.com{img_url}" + await self.cache.aset(key=CACHE_KEY_BING, value=backend, ttl=3600 * 24) + else: + backend: str = await self.cache.aget(CACHE_KEY_BING) + + LOG.debug(f"Requesting random picture from '{backend!s}'.") + response = await client.request(method="GET", url=backend, follow_redirects=True) if response.status_code != web.HTTPOk.status_code: @@ -1751,6 +1680,8 @@ class HttpAPI(Common): await self.cache.aset(key=CACHE_KEY, value=data, ttl=3600) + LOG.debug(f"Random background image from '{backend!s}' cached.") + return web.Response( body=data.get("content"), headers={ @@ -1766,6 +1697,8 @@ class HttpAPI(Common): data={"error": "failed to retrieve the random background image."}, status=web.HTTPInternalServerError.status_code, ) + finally: + self.isRequestingBackground = False @route("GET", "api/file/ffprobe/{file:.*}", "ffprobe") async def get_ffprobe(self, request: Request) -> Response: @@ -1791,7 +1724,7 @@ class HttpAPI(Common): headers={ "Location": str( self.app.router["ffprobe"].url_for( - file=str(realFile).replace(self.config.download_path, "").strip("/") + file=str(realFile.relative_to(self.config.download_path).as_posix()).strip("/") ) ), }, @@ -1830,7 +1763,7 @@ class HttpAPI(Common): headers={ "Location": str( self.app.router["file_info"].url_for( - file=str(realFile).replace(self.config.download_path, "").strip("/") + file=str(realFile.relative_to(self.config.download_path).as_posix()).strip("/") ) ), }, @@ -1842,7 +1775,7 @@ class HttpAPI(Common): ff_info = await ffprobe(realFile) response = { - "title": str(Path(realFile).stem), + "title": realFile.stem, "ffprobe": ff_info, "mimetype": get_mime_type(ff_info.get("metadata", {}), realFile), "sidecar": get_file_sidecar(realFile), @@ -1850,11 +1783,9 @@ class HttpAPI(Common): for key in response["sidecar"]: for i, f in enumerate(response["sidecar"][key]): - response["sidecar"][key][i]["file"] = ( - str(Path(realFile).with_name(Path(f["file"]).name)) - .replace(self.config.download_path, "") - .strip("/") - ) + response["sidecar"][key][i]["file"] = str( + realFile.with_name(f["file"].name).relative_to(self.config.download_path) + ).strip("/") return web.json_response(data=response, status=web.HTTPOk.status_code, dumps=self.encoder.encode) except Exception as e: @@ -1925,9 +1856,6 @@ class HttpAPI(Common): targets.append(ins.make_target(item)) try: - if len(targets) < 1: - ins.clear() - ins.save(targets=targets) ins.load() except Exception as e: @@ -1971,20 +1899,25 @@ class HttpAPI(Common): if not self.config.browser_enabled: return web.json_response(data={"error": "File browser is disabled."}, status=web.HTTPForbidden.status_code) - path = request.match_info.get("path") - path = "/" if not path else unquote_plus(path) + req_path: str = request.match_info.get("path") + req_path: str = "/" if not req_path else unquote_plus(req_path) - test = os.path.realpath(os.path.join(self.config.download_path, path)) - if not os.path.exists(test): + test: Path = Path(self.config.download_path).joinpath(req_path) + if not test.exists(): return web.json_response( - data={"error": f"path '{path}' does not exist."}, status=web.HTTPNotFound.status_code + data={"error": f"path '{req_path}' does not exist."}, status=web.HTTPNotFound.status_code + ) + + if not test.is_dir() and not test.is_symlink(): + return web.json_response( + data={"error": f"path '{req_path}' is not a directory."}, status=web.HTTPBadRequest.status_code ) try: return web.json_response( data={ - "path": path, - "contents": get_files(base_path=self.config.download_path, dir=path), + "path": req_path, + "contents": get_files(base_path=Path(self.config.download_path), dir=req_path), }, status=web.HTTPOk.status_code, dumps=self.encoder.encode, @@ -1992,3 +1925,159 @@ class HttpAPI(Common): except OSError as e: LOG.exception(e) return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code) + + @route("GET", "api/dev/loop") + async def debug_asyncio(self, _: Request) -> Response: + if not self.config.is_dev(): + return web.json_response( + data={"error": "This endpoint is only available in development mode."}, + status=web.HTTPForbidden.status_code, + ) + + import traceback + + tasks = [] + for task in asyncio.all_tasks(): + task_info = {"task": str(task), "stack": []} + for frame in task.get_stack(): + formatted = traceback.format_stack(f=frame) + task_info["stack"].extend(formatted) + + tasks.append(task_info) + + return web.json_response( + data={ + "total_tasks": len(tasks), + "loop": str(asyncio.get_event_loop()), + "tasks": tasks, + }, + status=web.HTTPOk.status_code, + dumps=self.encoder.encode, + ) + + @route("GET", "api/dev/workers", "pool_list") + async def pool_list(self, _) -> Response: + """ + Get the workers status. + + Args: + _: The request object. + + Returns: + Response: The response object. + + """ + if not self.config.is_dev(): + return web.json_response( + {"error": "This endpoint is only available in development mode."}, + status=web.HTTPNotFound.status_code, + ) + + if self.queue.pool is None: + return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code) + + status = self.queue.pool.get_workers_status() + + data = [] + + for worker in status: + worker_status = status.get(worker) + data.append( + { + "id": worker, + "data": {"status": "Waiting for download."} if worker_status is None else worker_status, + } + ) + + return web.json_response( + data={ + "open": self.queue.pool.has_open_workers(), + "count": self.queue.pool.get_available_workers(), + "workers": data, + }, + status=web.HTTPOk.status_code, + dumps=lambda obj: json.dumps(obj, default=lambda o: f"<>"), + ) + + @route("POST", "api/dev/workers", "pool_start") + async def pool_restart(self, _) -> Response: + """ + Restart the workers pool. + + Args: + _: The request object. + + Returns: + Response: The response object. + + """ + if not self.config.is_dev(): + return web.json_response( + {"error": "This endpoint is only available in development mode."}, + status=web.HTTPNotFound.status_code, + ) + + if self.queue.pool is None: + return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code) + + self.queue.pool.start() + + return web.json_response({"message": "Workers pool being restarted."}, status=web.HTTPOk.status_code) + + @route("PATCH", "api/dev/workers/{id}", "worker_restart") + async def worker_restart(self, request: Request) -> Response: + """ + Restart a worker. + + Args: + request (Request): The request object. + + Returns: + Response: The response object + + """ + if not self.config.is_dev(): + return web.json_response( + {"error": "This endpoint is only available in development mode."}, + status=web.HTTPNotFound.status_code, + ) + + worker_id: str = request.match_info.get("id") + if not worker_id: + return web.json_response({"error": "worker id is required."}, status=web.HTTPBadRequest.status_code) + + if self.queue.pool is None: + return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code) + + status = await self.queue.pool.restart(worker_id, "requested by user.") + + return web.json_response({"status": "restarted" if status else "in_error_state"}, status=web.HTTPOk.status_code) + + @route("DELETE", "api/dev/workers/{id}", "worker_stop") + async def worker_stop(self, request: Request) -> Response: + """ + Stop a worker. + + Args: + request (Request): The request object. + + Returns: + Response: The response object. + + """ + if not self.config.is_dev(): + return web.json_response( + {"error": "This endpoint is only available in development mode."}, + status=web.HTTPNotFound.status_code, + ) + + worker_id: str = request.match_info.get("id") + if not worker_id: + raise web.HTTPBadRequest(text="worker id is required.") + + if self.queue.pool is None: + return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code) + + status = await self.queue.pool.stop(worker_id, "requested by user.") + + return web.json_response({"status": "stopped" if status else "in_error_state"}, status=web.HTTPOk.status_code) diff --git a/app/library/HttpSocket.py b/app/library/HttpSocket.py index 87496e56..9724f905 100644 --- a/app/library/HttpSocket.py +++ b/app/library/HttpSocket.py @@ -3,10 +3,10 @@ import errno import functools import logging import os -import pty import shlex import time from datetime import UTC, datetime +from pathlib import Path import anyio import socketio @@ -50,8 +50,17 @@ class HttpSocket(Common): self.queue = queue or DownloadQueue.get_instance() self._notify = EventBus.get_instance() - #logger=True, engineio_logger=True, - self.sio = sio or socketio.AsyncServer(cors_allowed_origins="*") + # logger=True, engineio_logger=True, + self.sio = sio or socketio.AsyncServer( + async_handlers=True, + async_mode="aiohttp", + cors_allowed_origins=[], + transports=["websocket"], + logger=self.config.debug, + engineio_logger=self.config.debug, + ping_interval=10, + ping_timeout=5, + ) encoder = encoder or Encoder() def emit(e: Event, _, **kwargs): @@ -77,6 +86,12 @@ class HttpSocket(Common): async def on_shutdown(self, _: web.Application): LOG.debug("Shutting down socket server.") + for sid in self.sio.manager.get_participants("/", None): + LOG.debug(f"Disconnecting client '{sid}'.") + await self.sio.disconnect(sid[0], namespace="/") + + LOG.debug("Socket server shutdown complete.") + def attach(self, app: web.Application): self.sio.attach(app, socketio_path=f"{self.config.base_path.rstrip('/')}/socket.io") @@ -121,23 +136,47 @@ class HttpSocket(Common): } ) - master_fd, slave_fd = pty.openpty() + try: + import pty + + master_fd, slave_fd = pty.openpty() + stdin_arg = asyncio.subprocess.DEVNULL + stdout_arg = stderr_arg = slave_fd + use_pty = True + except ImportError: + use_pty = False + master_fd = slave_fd = None + stdin_arg = asyncio.subprocess.DEVNULL + stdout_arg = asyncio.subprocess.PIPE + stderr_arg = asyncio.subprocess.STDOUT proc = await asyncio.create_subprocess_exec( *args, cwd=self.config.download_path, - stdin=asyncio.subprocess.DEVNULL, - stdout=slave_fd, - stderr=slave_fd, + stdin=stdin_arg, + stdout=stdout_arg, + stderr=stderr_arg, env=_env, ) - try: - os.close(slave_fd) - except Exception as e: - LOG.error(f"Error closing PTY. '{e!s}'.") + if use_pty: + try: + os.close(slave_fd) + except Exception as e: + LOG.error(f"Error closing PTY. '{e!s}'.") + + async def reader(sid: str): + if use_pty is False: + assert proc.stdout is not None + async for raw_line in proc.stdout: + line = raw_line.rstrip(b"\n") + await self._notify.emit( + Events.CLI_OUTPUT, + data={"type": "stdout", "line": line.decode("utf-8", errors="replace")}, + to=sid, + ) + return - async def read_pty(sid: str): loop = asyncio.get_running_loop() buffer = b"" while True: @@ -172,7 +211,7 @@ class HttpSocket(Common): LOG.error(f"Error closing PTY. '{e!s}'.") # Start reading output from PTY - read_task = asyncio.create_task(read_pty(sid=sid)) + read_task = asyncio.create_task(reader(sid=sid)) # Wait until process finishes returncode = await proc.wait() @@ -266,13 +305,17 @@ class HttpSocket(Common): manual_archive = self.config.manual_archive if manual_archive: + manual_archive = Path(manual_archive) + + if not manual_archive.exists(): + manual_archive.touch(exist_ok=True) + previouslyArchived = False - if os.path.exists(manual_archive): - async with await anyio.open_file(manual_archive) as f: - async for line in f: - if idDict["archive_id"] in line: - previouslyArchived = True - break + async with await anyio.open_file(manual_archive) as f: + async for line in f: + if idDict["archive_id"] in line: + previouslyArchived = True + break if not previouslyArchived: async with await anyio.open_file(manual_archive, "a") as f: @@ -290,9 +333,7 @@ class HttpSocket(Common): "paused": self.queue.is_paused(), } - # get download folder listing. - downloadPath: str = self.config.download_path - data["folders"] = [name for name in os.listdir(downloadPath) if os.path.isdir(os.path.join(downloadPath, name))] + data["folders"] = [folder.name for folder in Path(self.config.download_path).iterdir() if folder.is_dir()] await self._notify.emit(Events.INITIAL_DATA, data=data, to=sid) @@ -332,10 +373,11 @@ class HttpSocket(Common): await self.subscribe_emit(event=event, data=data) if "log_lines" == event and self.log_task is None: - LOG.debug("Starting log tailing task.") + log_file = Path(self.config.config_path) / "logs" / "app.log" + LOG.debug(f"Starting tailing '{log_file!s}'.") self.log_task = asyncio.create_task( tail_log( - file=os.path.join(self.config.config_path, "logs", "app.log"), + file=log_file, emitter=emit_logs, ), name="tail_log", diff --git a/app/library/ItemDTO.py b/app/library/ItemDTO.py index 19436886..68c0e804 100644 --- a/app/library/ItemDTO.py +++ b/app/library/ItemDTO.py @@ -247,4 +247,5 @@ class ItemDTO: "output_template", "output_template_chapter", "config", + "temp_path" ) diff --git a/app/library/M3u8.py b/app/library/M3u8.py index a6b35b2c..3bbe3c00 100644 --- a/app/library/M3u8.py +++ b/app/library/M3u8.py @@ -12,9 +12,9 @@ class M3u8: ok_vcodecs: tuple = ("h264", "x264", "avc") ok_acodecs: tuple = ("aac", "m4a", "mp3") - def __init__(self, download_path: str, url: str, segment_duration: float | None = None): + def __init__(self, download_path: Path, url: str, segment_duration: float | None = None): self.url = url - self.download_path = download_path + self.download_path: Path = download_path self.duration = float(segment_duration) if segment_duration is not None else self.duration async def make_stream(self, file: Path) -> str: @@ -23,7 +23,7 @@ class M3u8: except UnicodeDecodeError: pass - file = str(file).replace(self.download_path, "").strip("/") + urlPath: str = str(file.relative_to(self.download_path).as_posix()).strip("/") if "duration" not in ff.metadata: error = f"Unable to get '{file}' play duration." @@ -31,7 +31,7 @@ class M3u8: duration: float = float(ff.metadata.get("duration")) - m3u8 = [] + m3u8: list = [] m3u8.append("#EXTM3U") m3u8.append("#EXT-X-VERSION:3") @@ -57,7 +57,7 @@ class M3u8: m3u8.append(f"#EXTINF:{segmentSize},") - url = f"{self.url}api/player/segments/{i}/{quote(file)}.ts" + url = f"{self.url}api/player/segments/{i}/{quote(urlPath)}.ts" if len(segmentParams) > 0: url += "?" + "&".join([f"{key}={value}" for key, value in segmentParams.items()]) @@ -70,13 +70,15 @@ class M3u8: async def make_subtitle(self, file: Path, duration: float) -> str: m3u8 = [] + urlPath: str = str(file.relative_to(self.download_path).as_posix()).strip("/") + m3u8.append("#EXTM3U") m3u8.append("#EXT-X-VERSION:3") m3u8.append(f"#EXT-X-TARGETDURATION:{int(self.duration)}") m3u8.append("#EXT-X-MEDIA-SEQUENCE:0") m3u8.append("#EXT-X-PLAYLIST-TYPE:VOD") m3u8.append(f"#EXTINF:{duration},") - m3u8.append(f"{self.url}api/player/subtitle/{quote(str(file).replace(self.download_path, '').strip('/'))}.vtt") + m3u8.append(f"{self.url}api/player/subtitle/{quote(urlPath)}.vtt") m3u8.append("#EXT-X-ENDLIST") return "\n".join(m3u8) diff --git a/app/library/Notifications.py b/app/library/Notifications.py index 45ab1182..e0ca7f91 100644 --- a/app/library/Notifications.py +++ b/app/library/Notifications.py @@ -1,10 +1,10 @@ import asyncio import json import logging -import os from collections.abc import Awaitable from dataclasses import dataclass, field from datetime import datetime +from pathlib import Path from typing import Any import httpx @@ -17,7 +17,6 @@ from .Events import Event, EventBus, Events from .ItemDTO import ItemDTO from .Singleton import Singleton from .Utils import validate_uuid -from .version import APP_VERSION LOG = logging.getLogger("notifications") @@ -135,14 +134,14 @@ class Notification(metaclass=Singleton): config: Config = config or Config.get_instance() self._debug = config.debug - self._file: str = file or os.path.join(config.config_path, "notifications.json") + self._file: Path = Path(file) if file else Path(config.config_path).joinpath("notifications.json") self._client: httpx.AsyncClient = client or httpx.AsyncClient() self._encoder: Encoder = encoder or Encoder() + self._version = config.version - if os.path.exists(self._file): + if self._file.exists() and "600" != self._file.stat().st_mode: try: - if "600" != oct(os.stat(self._file).st_mode)[-3:]: - os.chmod(self._file, 0o600) + self._file.chmod(0o600) except Exception: pass @@ -184,50 +183,45 @@ class Notification(metaclass=Singleton): Notification: The Notification instance. """ - LOG.info(f"Saving notification targets to '{self._file}'.") try: - with open(self._file, "w") as f: - json.dump([t.serialize() for t in targets], fp=f, indent=4) + self._file.write_text(json.dumps([t.serialize() for t in targets], indent=4)) + LOG.info(f"Updated '{self._file}'.") except Exception as e: LOG.exception(e) - LOG.error(f"Error saving notification targets to '{self._file}'. '{e!s}'") + LOG.error(f"Error saving '{self._file}'. '{e!s}'") return self def load(self) -> "Notification": """Load or reload notification targets from the file.""" if len(self._targets) > 0: - LOG.info("Clearing existing notification targets.") self.clear() - if not os.path.exists(self._file) or os.path.getsize(self._file) < 10: + if not self._file.exists() or self._file.stat().st_size < 1: return self targets = [] - LOG.info(f"Loading notification targets from '{self._file}'.") - try: - with open(self._file) as f: - targets = json.load(f) + LOG.info(f"Loading '{self._file}'.") + targets = json.loads(self._file.read_text()) except Exception as e: - LOG.error(f"Error loading notification targets from '{self._file}'. '{e!s}'") + LOG.error(f"Error loading '{self._file}'. '{e!s}'") for target in targets: try: try: Notification.validate(target) + target: Target = self.make_target(target) except ValueError as e: name = target.get("name") or target.get("id") or target.get("request", {}).get("url") or "unknown" LOG.error(f"Invalid notification target '{name}'. '{e!s}'") continue - target = self.make_target(target) - self._targets.append(target) LOG.info( - f"Will send {target.request.type} request on '{', '.join(target.on) if len(target.on) > 0 else 'all events'}' to '{target.name}'." + f"Send '{target.request.type}' request on '{', '.join(target.on) if len(target.on) > 0 else 'all events'}' to '{target.name}'." ) except Exception as e: LOG.error(f"Error loading notification target '{target}'. '{e!s}'") @@ -360,7 +354,7 @@ class Notification(metaclass=Singleton): "method": target.request.method.upper(), "url": target.request.url, "headers": { - "User-Agent": f"YTPTube/{APP_VERSION}", + "User-Agent": f"YTPTube/{self._version}", "X-Event-Id": ev.id, "X-Event": ev.event, "Content-Type": "application/json" diff --git a/app/library/PackageInstaller.py b/app/library/PackageInstaller.py index 570a0509..2052f896 100644 --- a/app/library/PackageInstaller.py +++ b/app/library/PackageInstaller.py @@ -3,6 +3,7 @@ import logging import os import subprocess import sys +from pathlib import Path LOG = logging.getLogger("package_installer") @@ -12,11 +13,15 @@ class Packages: from_env = env.split() if env else [] from_file = [] - if os.path.exists(file) and os.access(file, os.R_OK): - with open(file) as f: - from_file = [pkg.strip() for pkg in f if pkg.strip()] + if file: + file = Path(file) + if file.exists() and os.access(str(file), os.R_OK): + with open(file) as f: + from_file: list[str] = [pkg.strip() for pkg in f if pkg.strip()] + else: + LOG.error(f"pip packages file '{file}' doesn't exist or is not readable.") - self.packages: list = list(set(from_env + from_file)) + self.packages: list[str] = list(set(from_env + from_file)) self.upgrade = bool(upgrade) def has_packages(self) -> bool: diff --git a/app/library/Playlist.py b/app/library/Playlist.py index 563956e1..af648ebb 100644 --- a/app/library/Playlist.py +++ b/app/library/Playlist.py @@ -8,12 +8,12 @@ from .Utils import StreamingError, get_file_sidecar class Playlist: _url: str = None - def __init__(self, download_path: str, url: str): - self.url = url - self.download_path = download_path + def __init__(self, download_path: Path, url: str): + self.url: str = url + self.download_path: Path = download_path async def make(self, file: Path) -> str: - ref = str(file).replace(self.download_path, "").strip("/") + ref: str = Path(str(file.relative_to(self.download_path)).strip("/")) try: ff = await ffprobe(file) @@ -24,19 +24,19 @@ class Playlist: msg = f"Unable to get '{ref}' duration." raise StreamingError(msg) - playlist = [] + playlist: list[str] = [] playlist.append("#EXTM3U") - subs = "" + subs: str = "" duration: float = float(ff.metadata.get("duration")) for sub_file in get_file_sidecar(file).get("subtitle", []): - lang = sub_file["lang"] - item = Path(sub_file["file"]) - name = sub_file["name"] + lang: str = sub_file["lang"] + item: Path = sub_file["file"] + name: str = sub_file["name"] subs = ',SUBTITLES="subs"' - url = f"{self.url}api/player/m3u8/subtitle/{quote(str(Path(ref).with_name(item.name)))}.m3u8?duration={duration}" + url = f"{self.url}api/player/m3u8/subtitle/{quote(str(ref.with_name(item.name)))}.m3u8?duration={duration}" playlist.append( f'#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="{name}",DEFAULT=NO,AUTOSELECT=NO,FORCED=NO,LANGUAGE="{lang}",URI="{url}"' ) diff --git a/app/library/Presets.py b/app/library/Presets.py index ee695ea4..dbb044ff 100644 --- a/app/library/Presets.py +++ b/app/library/Presets.py @@ -1,8 +1,8 @@ import json import logging -import os import uuid from dataclasses import dataclass, field +from pathlib import Path from typing import Any from aiohttp import web @@ -11,10 +11,51 @@ from .config import Config from .encoder import Encoder from .Events import EventBus, Events from .Singleton import Singleton -from .Utils import arg_converter, clean_item +from .Utils import arg_converter, init_class LOG = logging.getLogger("presets") +DEFAULT_PRESETS: list[dict[int, dict[str, str | bool]]] = [ + { + "id": "3e163c6c-64eb-4448-924f-814b629b3810", + "name": "default", + "default": True, + }, + { + "id": "5bf9c42b-8852-468a-99f5-915622dfba25", + "name": "Best video and audio", + "cli": "--format 'bv+ba/b'", + "default": True, + }, + { + "id": "441675ed-b739-40f0-a0b0-1ecfcb9dc48b", + "name": "1080p H264/m4a or best available", + "cli": "-S vcodec:h264 --format 'bv[height<=1080][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]'", + "default": True, + }, + { + "id": "9719fcc3-4cf2-4d88-b1e4-74dff3dba00e", + "name": "720p h264/m4a or best available", + "cli": "-S vcodec:h264 --format 'bv[height<=720][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]'", + "default": True, + }, + { + "id": "a6fd4b25-2b3e-458d-bb57-b75e41cc4330", + "name": "Audio only", + "cli": "--extract-audio --add-chapters --embed-metadata --embed-thumbnail --format 'bestaudio/best'", + "default": True, + }, + { + "id": "2ade2c28-cad4-4a06-b7eb-2439fdf46f60", + "name": "yt-dlp info reader plugin", + "description": 'This preset generate specific filename format and metadata to work with yt-dlp info reader plugins for jellyfin/emby and plex and to make play state sync work for WatchState.\n\nThere is one more step you need to do via Other > Terminal if you have it enabled or directly from container shell\n\nyt-dlp -I0 --write-info-json --write-thumbnail --convert-thumbnails jpg --paths /downloads/youtube -o "%(channel|Unknown_title)s [%(channel_id|Unknown_id)s]/%(title).180B [%(channel_id|Unknown_id)s].%(ext)s" -- https://www.youtube.com/channel/UCClfFsWcT3N2I7VTXXyt84A\n\nChange the url to the channel you want to download.\n\nFor more information please visit \nhttps://github.com/arabcoders/watchstate/blob/master/FAQ.md#how-to-get-watchstate-working-with-youtube-contentlibrary', + "folder": "youtube", + "template": "%(channel)s %(channel_id|Unknown_id)s/Season %(release_date>%Y,upload_date>%Y|Unknown)s/%(release_date>%Y%m%d,upload_date>%Y%m%d)s - %(title).180B [%(extractor)s-%(id)s].%(ext)s", + "cli": "--windows-filenames --write-info-json --embed-info-json \n--convert-thumbnails jpg --write-thumbnail --embed-metadata", + "default": True, + }, +] + @dataclass(kw_only=True) class Preset: @@ -65,28 +106,26 @@ class Presets(metaclass=Singleton): _default: list[Preset] = [] - def __init__(self, file: str | None = None, config: Config | None = None): + def __init__(self, file: str | Path | None = None, config: Config | None = None): Presets._instance = self config = config or Config.get_instance() - self._file: str = file or os.path.join(config.config_path, "presets.json") + self._file: Path = Path(file) if file else Path(config.config_path).joinpath("presets.json") - if os.path.exists(self._file) and "600" != oct(os.stat(self._file).st_mode)[-3:]: + if self._file.exists() and "600" != self._file.stat().st_mode: try: - os.chmod(self._file, 0o600) + self._file.chmod(0o600) except Exception: pass - default_file = os.path.join(os.path.dirname(__file__), "presets.json") - with open(default_file) as f: - for i, preset in enumerate(json.load(f)): - try: - self.validate(preset) - self._default.append(Preset(**preset)) - except Exception as e: - LOG.error(f"Failed to parse '{default_file}:{i}'. '{e!s}'.") - continue + for i, preset in enumerate(DEFAULT_PRESETS): + try: + self.validate(preset) + self._default.append(init_class(Preset, preset)) + except Exception as e: + LOG.error(f"Failed to parse default preset ':{i}'. '{e!s}'.") + continue def event_handler(_, __): msg = "Not implemented" @@ -138,13 +177,12 @@ class Presets(metaclass=Singleton): """ self.clear() - if not os.path.exists(self._file) or os.path.getsize(self._file) < 10: + if not self._file.exists() or self._file.stat().st_size < 10: return self - LOG.info(f"Loading '{self._file}'.") try: - with open(self._file) as f: - presets = json.load(f) + LOG.info(f"Loading '{self._file}'.") + presets: dict = json.loads(self._file.read_text()) except Exception as e: LOG.error(f"Failed to parse '{self._file}'. '{e}'.") return self @@ -160,7 +198,6 @@ class Presets(metaclass=Singleton): preset["id"] = str(uuid.uuid4()) need_save = True - preset, preset_status = clean_item(preset, keys=("args", "postprocessors")) if preset.get("format"): if not preset.get("cli"): preset.update({"cli": f"--format {preset['format']}"}) @@ -172,10 +209,7 @@ class Presets(metaclass=Singleton): preset.pop("format") need_save = True - preset = Preset(**preset) - - if preset_status: - need_save = True + preset: Preset = init_class(Preset, preset) self._items.append(preset) except Exception as e: @@ -183,7 +217,7 @@ class Presets(metaclass=Singleton): continue if need_save: - LOG.info(f"Saving '{self._file}' due to changes.") + LOG.info(f"Saving '{self._file}'.") self.save(self._items) return self @@ -255,7 +289,7 @@ class Presets(metaclass=Singleton): for i, preset in enumerate(items): try: if not isinstance(preset, Preset): - preset = Preset(**preset) + preset: Preset = init_class(Preset, preset) items[i] = preset except Exception as e: LOG.error(f"Failed to save item '{i}' due to parsing error. '{e!s}'.") @@ -268,8 +302,9 @@ class Presets(metaclass=Singleton): continue try: - with open(self._file, "w") as f: - json.dump(obj=[preset.serialize() for preset in items if preset.default is False], fp=f, indent=4) + self._file.write_text( + json.dumps(obj=[preset.serialize() for preset in items if preset.default is False], indent=4) + ) LOG.info(f"Saved '{self._file}'.") except Exception as e: diff --git a/app/library/Segments.py b/app/library/Segments.py index 5252a56f..8473e0ff 100644 --- a/app/library/Segments.py +++ b/app/library/Segments.py @@ -1,7 +1,6 @@ import asyncio import hashlib import logging -import os import tempfile from pathlib import Path @@ -33,13 +32,14 @@ class Segments: except UnicodeDecodeError: pass - tmpDir = tempfile.gettempdir() - tmpFile = os.path.join(tmpDir, f"ytptube_stream.{hashlib.sha256(str(file).encode()).hexdigest()}") + tmpFile = Path(tempfile.gettempdir()).joinpath( + f"ytptube_stream.{hashlib.sha256(str(file).encode()).hexdigest()}" + ) - if not os.path.exists(tmpFile): - os.symlink(file, tmpFile) + if not tmpFile.exists(): + tmpFile.symlink_to(file, target_is_directory=False) - startTime = f"{0:.6f}" if self.index == 0 else f"{self.duration * self.index:.6f}" + startTime: str = f"{0:.6f}" if self.index == 0 else f"{self.duration * self.index:.6f}" fargs = [ "-xerror", @@ -78,7 +78,7 @@ class Segments: return fargs async def stream(self, file: Path, resp: web.StreamResponse): - ffmpeg_args = await self.build_ffmpeg_args(file) + ffmpeg_args: list[str] = await self.build_ffmpeg_args(file) proc = await asyncio.create_subprocess_exec( "ffmpeg", @@ -94,7 +94,7 @@ class Segments: try: while True: - chunk = await proc.stdout.read(1024 * 64) + chunk: bytes = await proc.stdout.read(1024 * 64) if not chunk: break try: diff --git a/app/library/Subtitle.py b/app/library/Subtitle.py index 495da32d..e78f1476 100644 --- a/app/library/Subtitle.py +++ b/app/library/Subtitle.py @@ -22,9 +22,6 @@ SubstationFormat.ms_to_timestamp = ms_to_timestamp class Subtitle: - def __init__(self, download_path: str): - self.download_path = download_path - async def make(self, file: Path) -> str: if file.suffix not in ALLOWED_SUBS_EXTENSIONS: msg = f"File '{file}' subtitle type is not supported." @@ -34,7 +31,7 @@ class Subtitle: async with await anyio.open_file(file) as f: return await f.read() - subs = pysubs2.load(path=str(file)) + subs: pysubs2.SSAFile = pysubs2.load(path=str(file)) if len(subs.events) < 1: msg = f"No subtitle events were found in '{file}'." @@ -43,7 +40,10 @@ class Subtitle: if len(subs.events) < 2: return subs.to_string("vtt") - if subs.events[0].end == subs.events[len(subs.events) - 1].end: - subs.events.pop(0) + try: + if subs.events[0].end == subs.events[len(subs.events) - 1].end: + subs.events.pop(0) + except Exception: + pass return subs.to_string("vtt") diff --git a/app/library/Tasks.py b/app/library/Tasks.py index 4bf22320..71666845 100644 --- a/app/library/Tasks.py +++ b/app/library/Tasks.py @@ -1,10 +1,10 @@ import asyncio import json import logging -import os import time from dataclasses import dataclass from datetime import UTC, datetime +from pathlib import Path from typing import Any import httpx @@ -15,7 +15,7 @@ from .encoder import Encoder from .Events import EventBus, Events, error, info, success from .Scheduler import Scheduler from .Singleton import Singleton -from .Utils import clean_item +from .Utils import init_class LOG = logging.getLogger("tasks") @@ -65,18 +65,18 @@ class Tasks(metaclass=Singleton): config = config or Config.get_instance() - self._debug = config.debug - self._default_preset = config.default_preset - self._file = file or os.path.join(config.config_path, "tasks.json") - self._client = client or httpx.AsyncClient() - self._encoder = encoder or Encoder() - self._loop = loop or asyncio.get_event_loop() - self._scheduler = scheduler or Scheduler.get_instance() - self._notify = EventBus.get_instance() + self._debug: bool = config.debug + self._default_preset: str = config.default_preset + self._file: Path = Path(file) if file else Path(config.config_path).joinpath("tasks.json") + self._client: httpx.AsyncClient = client or httpx.AsyncClient() + self._encoder: Encoder = encoder or Encoder() + self._loop: asyncio.AbstractEventLoop = loop or asyncio.get_event_loop() + self._scheduler: Scheduler = scheduler or Scheduler.get_instance() + self._notify: EventBus = EventBus.get_instance() - if os.path.exists(self._file) and "600" != oct(os.stat(self._file).st_mode)[-3:]: + if self._file.exists() and "600" != self._file.stat().st_mode: try: - os.chmod(self._file, 0o600) + self._file.chmod(0o600) except Exception: pass @@ -126,29 +126,23 @@ class Tasks(metaclass=Singleton): """ self.clear() - if not os.path.exists(self._file) or os.path.getsize(self._file) < 10: + if not self._file.exists() or self._file.stat().st_size < 1: return self - LOG.info(f"Loading tasks from '{self._file}'.") try: - with open(self._file) as f: - tasks = json.load(f) + LOG.info(f"Loading '{self._file}'.") + tasks = json.loads(self._file.read_text()) except Exception as e: - LOG.error(f"Failed to parse tasks from '{self._file}'. '{e!s}'.") + LOG.error(f"Error loading '{self._file}'. '{e!s}'.") return self if not tasks or len(tasks) < 1: - LOG.info(f"No tasks were defined in '{self._file}'.") return self - need_save = False for i, task in enumerate(tasks): try: - task, task_status = clean_item(task, keys=("cookies", "config")) - self.validate(task) - task = Task(**task) - if task_status: - need_save = True + Tasks.validate(task) + task: Task = init_class(Task, task) except Exception as e: LOG.error(f"Failed to parse task at list position '{i}'. '{e!s}'.") continue @@ -172,11 +166,7 @@ class Tasks(metaclass=Singleton): LOG.info(f"Task '{i}: {task.name}' queued to be executed '{schedule_time}'.") except Exception as e: LOG.exception(e) - LOG.error(f"Failed to queue task '{i}: {task.name}'. '{e!s}'.") - - if need_save: - LOG.info("Updating tasks file to remove old keys.") - self.save(self.get_all()) + LOG.error(f"Failed to queue '{i}: {task.name}'. '{e!s}'.") return self @@ -193,18 +183,19 @@ class Tasks(metaclass=Singleton): for task in self._tasks: try: - LOG.info(f"Stopping task '{task.id}: {task.name}'.") + LOG.info(f"Stopping '{task.id}: {task.name}'.") self._scheduler.remove(task.id) except Exception as e: if not shutdown: LOG.exception(e) - LOG.error(f"Failed to stop task '{task.id}: {task.name}'. '{e!s}'.") + LOG.error(f"Failed to stop '{task.id}: {task.name}'. '{e!s}'.") self._tasks.clear() return self - def validate(self, task: Task | dict) -> bool: + @staticmethod + def validate(task: Task | dict) -> bool: """ Validate the task. @@ -262,31 +253,28 @@ class Tasks(metaclass=Singleton): """ for i, task in enumerate(tasks): - try: - if not isinstance(task, Task): - task = Task(**task) - tasks[i] = task - except Exception as e: - LOG.error(f"Failed to save task '{i}' unable to parse task. '{e!s}'.") - continue - try: self.validate(task) + + if not isinstance(task, Task): + task: Task = init_class(Task, task) + tasks[i] = task except ValueError as e: - LOG.error(f"Failed to add task '{i}: {task.name}'. '{e}'.") + LOG.error(f"Failed to validate item '{i}: {task.name}'. '{e}'.") + continue + except Exception as e: + LOG.error(f"Failed to save task '{i}'. '{e!s}'.") continue try: - with open(self._file, "w") as f: - json.dump(obj=[task.serialize() for task in tasks], fp=f, indent=4) - - LOG.info(f"Tasks saved to '{self._file}'.") + self._file.write_text(json.dumps([i.serialize() for i in tasks], indent=4)) + LOG.info(f"Updated '{self._file}'.") except Exception as e: - LOG.error(f"Failed to save tasks to '{self._file}'. '{e!s}'.") + LOG.error(f"Error saving '{self._file}'. '{e!s}'.") return self - async def _runner(self, task: Task): + async def _runner(self, task: Task) -> None: """ Run the task. @@ -297,11 +285,11 @@ class Tasks(metaclass=Singleton): None """ + timeNow: str = datetime.now(UTC).isoformat() try: - timeNow = datetime.now(UTC).isoformat() - started = time.time() + started: float = time.time() if not task.url: - LOG.error(f"Failed to dispatch task '{task.id}: {task.name}'. No URL found.") + LOG.error(f"Failed to dispatch '{task.id}: {task.name}'. No URL found.") return preset: str = str(task.preset or self._default_preset) @@ -309,13 +297,10 @@ class Tasks(metaclass=Singleton): template: str = task.template if task.template else "" cli: str = task.cli if task.cli else "" - LOG.info(f"Task '{task.id}: {task.name}' dispatched at '{timeNow}'.") + LOG.info(f"Dispatched '{task.id}: {task.name}' at '{timeNow}'.") - tasks = [] - tasks.append( - self._notify.emit(Events.LOG_INFO, data=info(f"Task '{task.name}' dispatched at '{timeNow}'.")) - ) - tasks.append( + tasks: list = [ + self._notify.emit(Events.LOG_INFO, data=info(f"Dispatched '{task.name}' at '{timeNow}'.")), self._notify.emit( Events.ADD_URL, data={ @@ -327,22 +312,21 @@ class Tasks(metaclass=Singleton): }, id=task.id, ), - ) + ] await asyncio.wait_for(asyncio.gather(*tasks), timeout=None) timeNow = datetime.now(UTC).isoformat() - ended = time.time() - LOG.info(f"Task '{task.id}: {task.name}' completed at '{timeNow}' took '{ended - started:.2f}' seconds.") + ended: float = time.time() + LOG.info(f"Completed '{task.id}: {task.name}' at '{timeNow}' took '{ended - started:.2f}' seconds.") await self._notify.emit( Events.LOG_SUCCESS, - data=success(f"Task '{task.name}' completed in '{ended - started:.2f}' seconds."), + data=success(f"Completed '{task.name}' in '{ended - started:.2f}' seconds."), ) except Exception as e: - timeNow = datetime.now(UTC).isoformat() - LOG.error(f"Task '{task.id}: {task.name}' has failed to execute at '{timeNow}'. '{e!s}'.") + LOG.error(f"Failed to execute '{task.id}: {task.name}' at '{timeNow}'. '{e!s}'.") await self._notify.emit( - Events.ERROR, data=error(f"Task '{task.name}' failed to execute at '{timeNow}'. '{e!s}'.") + Events.ERROR, data=error(f"Failed to execute '{task.name}' at '{timeNow}'. '{e!s}'.") ) diff --git a/app/library/Utils.py b/app/library/Utils.py index d1d29296..c65cf851 100644 --- a/app/library/Utils.py +++ b/app/library/Utils.py @@ -5,7 +5,6 @@ import ipaddress import json import logging import os -import pathlib import re import shlex import socket @@ -13,6 +12,8 @@ import uuid from datetime import UTC, datetime, timedelta from functools import lru_cache from http.cookiejar import MozillaCookieJar +from pathlib import Path +from typing import TypeVar import yt_dlp from Crypto.Cipher import AES @@ -60,6 +61,8 @@ FILES_TYPE: list = [ DATETIME_PATTERN = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2}))\s?") +T = TypeVar("T") + class StreamingError(Exception): """Raised when an error occurs during streaming.""" @@ -70,7 +73,7 @@ class FileLogFormatter(logging.Formatter): return datetime.fromtimestamp(record.created).astimezone().isoformat(timespec="milliseconds") -def calc_download_path(base_path: str, folder: str | None = None, create_path: bool = True) -> str: +def calc_download_path(base_path: str | Path, folder: str | None = None, create_path: bool = True) -> str: """ Calculates download path and prevents folder traversal. @@ -83,23 +86,32 @@ def calc_download_path(base_path: str, folder: str | None = None, create_path: b Download path with base folder factored in. """ + if not isinstance(base_path, Path): + base_path = Path(base_path) + if not folder: - return base_path + return str(base_path) if folder.startswith("/"): folder = folder[1:] - realBasePath = os.path.realpath(base_path) - download_path = os.path.realpath(os.path.join(base_path, folder)) + realBasePath = base_path.resolve() + download_path = Path(realBasePath).joinpath(folder).resolve(strict=False) - if not download_path.startswith(realBasePath): + if not str(download_path).startswith(str(realBasePath)): msg = f'Folder "{folder}" must resolve inside the base download folder "{realBasePath}".' raise Exception(msg) - if not os.path.isdir(download_path) and create_path: - os.makedirs(download_path, exist_ok=True) + try: + download_path.relative_to(realBasePath) + except ValueError as e: + msg = f'Folder "{folder}" must resolve inside the base download folder "{realBasePath}".' + raise Exception(msg) from e - return download_path + if not download_path.is_dir() and create_path: + download_path.mkdir(parents=True, exist_ok=True) + + return str(download_path) def extract_info( @@ -248,7 +260,10 @@ def is_downloaded(archive_file: str, url: str) -> tuple[bool, dict[str | None, s """ idDict = {"id": None, "ie_key": None, "archive_id": None} - if not url or not archive_file or not os.path.exists(archive_file): + if not url or not archive_file: + return (False, idDict) + + if not Path(archive_file).exists(): return (False, idDict) idDict = get_archive_id(url=url) @@ -301,13 +316,13 @@ def load_file(file: str, check_type=None) -> tuple[dict | list, bool, str]: return ({}, False, f"{e}") -def check_id(file: pathlib.Path) -> bool | str: +def check_id(file: Path) -> bool | str: """ Check if we are able to get an id from the file name. if so check if any video file with the same id exists. Args: - file (pathlib.Path): File to check. + file (Path): File to check. Returns: bool|str: False if no file found, else the file path. @@ -477,12 +492,12 @@ def validate_uuid(uuid_str: str, version: int = 4) -> bool: return False -def get_file_sidecar(file: pathlib.Path) -> list[dict]: +def get_file_sidecar(file: Path) -> list[dict]: """ Get sidecar files for the given file. Args: - file (pathlib.Path): The video file. + file (Path): The video file. Returns: list: List of sidecar files. @@ -531,7 +546,7 @@ def get_file_sidecar(file: pathlib.Path) -> list[dict]: def get_possible_images(dir: str) -> list[dict]: images = [] - path_loc = pathlib.Path(dir, "test.jpg") + path_loc = Path(dir, "test.jpg") for filename in ["poster", "thumbnail", "artwork", "cover", "fanart"]: for ext in [".jpg", ".jpeg", ".png", ".webp"]: @@ -542,7 +557,7 @@ def get_possible_images(dir: str) -> list[dict]: return images -def get_mime_type(metadata: dict, file_path: pathlib.Path) -> str: +def get_mime_type(metadata: dict, file_path: Path) -> str: """ Determine the correct MIME type for a video file based on ffprobe metadata. @@ -587,32 +602,35 @@ def get_mime_type(metadata: dict, file_path: pathlib.Path) -> str: return "application/octet-stream" -def get_file(download_path: str, file: str | pathlib.Path) -> tuple[pathlib.Path, int]: +def get_file(download_path: str | Path, file: str | Path) -> tuple[Path, int]: """ Get the real file path. Args: - download_path (str): Base download path. - file (str|pathlib.Path): File path. + download_path (str|Path): Base download path. + file (str|Path): File path. Returns: - pathlib.Path: Real file path. + Path: Real file path. int: http status code. """ - file_path: str = os.path.normpath(os.path.join(download_path, str(file))) - if not file_path.startswith(download_path): - return (pathlib.Path(file_path), 404) + if not isinstance(download_path, Path): + download_path = Path(download_path) - realFile: str = pathlib.Path(calc_download_path(base_path=str(download_path), folder=str(file), create_path=False)) - if realFile.exists(): - return (realFile, 200) + try: + realFile: str = Path(calc_download_path(base_path=download_path, folder=str(file), create_path=False)) + if realFile.exists(): + return (realFile, 200) + except Exception as e: + LOG.error(f"Error calculating download path. {e!s}") + return (Path(file), 404) possibleFile = check_id(file=realFile) if not possibleFile: return (realFile, 404) - return (pathlib.Path(possibleFile), 302) + return (Path(possibleFile), 302) def encrypt_data(data: str, key: bytes) -> str: @@ -736,12 +754,12 @@ def get( return data -def get_files(base_path: str, dir: str | None = None): +def get_files(base_path: Path | str, dir: str | None = None): """ Get directory contents. Args: - base_path (str): Base download path. + base_path (Path|str): Base download path. dir (str): Directory to check. Returns: @@ -751,31 +769,33 @@ def get_files(base_path: str, dir: str | None = None): OSError: If the directory is invalid or not a directory. """ + if not isinstance(base_path, Path): + base_path = Path(base_path) + + base_path = base_path.resolve() + + dir_path = base_path if dir and dir != "/": - path = os.path.normpath(os.path.join(base_path, str(dir))) - if not path.startswith(base_path): - msg = f"Invalid path: '{dir}' - '{path}' - must be inside '{base_path}'." - raise OSError(msg) - dir_path = os.path.realpath(path) - else: - dir_path = base_path + dir_path: Path = base_path.joinpath(dir) - dir_path = pathlib.Path(dir_path) - if dir_path.is_symlink(): - try: - dir_path = dir_path.resolve() - except OSError as e: - LOG.warning(f"Skipping broken symlink: {dir_path} - {e}") - return [] + try: + dir_path = dir_path.resolve() + except OSError as e: + LOG.warning(f"Failed to resolve '{dir}' - {e}") + return [] - if not str(dir_path).startswith(base_path): - msg = f"Invalid path: '{dir_path}' - must be inside '{base_path}'." - LOG.warning(msg) + try: + dir_path.relative_to(base_path) + except ValueError: + LOG.warning(f"Invalid path: '{dir_path}' - must be inside '{base_path}'.") + return [] + + if not str(dir_path).startswith(str(base_path)): + LOG.warning(f"Invalid path: '{dir_path}' - must be inside '{base_path}'.") return [] if not dir_path.is_dir(): - msg = f"Invalid path: '{dir_path}' - must be a directory." - LOG.warning(msg) + LOG.warning(f"Invalid path: '{dir_path}' - must be a directory.") return [] contents: list = [] @@ -785,11 +805,14 @@ def get_files(base_path: str, dir: str | None = None): if file.is_symlink(): try: - test: pathlib.Path = file.resolve() - if not str(test).startswith(base_path): - msg = f"Invalid symlink: '{file}' - must resolve inside '{base_path}'." - LOG.warning(msg) + test: Path = file.resolve() + test.relative_to(base_path) + if not str(test).startswith(str(base_path)): + LOG.warning(f"Invalid symlink: '{file}' - must resolve inside '{base_path}'.") continue + except ValueError: + LOG.warning(f"Invalid symlink: '{file}' - must resolve inside '{base_path}'.") + continue except OSError: LOG.warning(f"Skipping broken symlink: {file}") continue @@ -813,7 +836,7 @@ def get_files(base_path: str, dir: str | None = None): "type": "file" if file.is_file() else "dir", "content_type": content_type, "name": file.name, - "path": str(file).replace(base_path, "").strip("/"), + "path": str(file.relative_to(base_path)).strip("/"), "size": stat.st_size, "mime": get_mime_type({}, file) if file.is_file() else "directory", "mtime": datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat(), @@ -883,12 +906,12 @@ def strip_newline(string: str) -> str: return res.strip() if res else "" -async def read_logfile(file: str, offset: int = 0, limit: int = 50) -> dict: +async def read_logfile(file: Path, offset: int = 0, limit: int = 50) -> dict: """ Read a log file and return a set of log lines along with pagination metadata. Args: - file (str): The log file path. + file (Path): The log file path. offset (int): Number of lines to skip from the end (newer entries). limit (int): Number of lines to return. @@ -903,7 +926,7 @@ async def read_logfile(file: str, offset: int = 0, limit: int = 50) -> dict: from anyio import open_file - if not pathlib.Path(file).exists(): + if not file.exists(): return {"logs": [], "next_offset": None, "end_is_reached": True} result = [] @@ -951,7 +974,7 @@ async def read_logfile(file: str, offset: int = 0, limit: int = 50) -> dict: return {"logs": [], "next_offset": None, "end_is_reached": True} -async def tail_log(file: str, emitter: callable, sleep_time: float = 0.5): +async def tail_log(file: Path, emitter: callable, sleep_time: float = 0.5): """ Continuously read a log file and emit new lines. @@ -966,7 +989,7 @@ async def tail_log(file: str, emitter: callable, sleep_time: float = 0.5): from anyio import open_file - if not pathlib.Path(file).exists(): + if not file.exists(): return try: @@ -993,7 +1016,7 @@ async def tail_log(file: str, emitter: callable, sleep_time: float = 0.5): return -def load_cookies(file: str) -> tuple[bool, MozillaCookieJar]: +def load_cookies(file: str | Path) -> tuple[bool, MozillaCookieJar]: """ Validate and load a cookie file. @@ -1007,7 +1030,7 @@ def load_cookies(file: str) -> tuple[bool, MozillaCookieJar]: try: from http.cookiejar import MozillaCookieJar - cookies = MozillaCookieJar(file, None, None) + cookies = MozillaCookieJar(str(file), None, None) cookies.load() return (True, cookies) @@ -1131,3 +1154,47 @@ def extract_ytdlp_logs(logs: list[str], filters: list[str | re.Pattern] = None) matched.extend(line for line in logs if line and any(p.search(line) for p in compiled)) return list(dict.fromkeys(matched)) + + +def delete_dir(dir: Path) -> bool: + """ + Delete a directory and all its contents. + + Args: + dir (Path): The directory to delete. + + Returns: + bool: True if the directory was deleted successfully, False otherwise. + + """ + if not dir or not dir.exists() or not dir.is_dir(): + return False + + try: + for item in dir.iterdir(): + if item.is_dir(): + delete_dir(item) + else: + item.unlink() + dir.rmdir() + return True + except Exception as e: + LOG.error(f"Failed to delete directory '{dir}': {e}") + return False + + +def init_class(cls: type[T], data: dict) -> T: + """ + Initialize a class instance with data from a dictionary, filtering out keys not present in the class fields. + + Args: + cls (type): The class to initialize. + data (dict): The data to use for initialization. + + Returns: + T: An instance of the class initialized with the provided data. + + """ + from dataclasses import fields + + return cls(**{k: v for k, v in data.items() if k in {f.name for f in fields(cls)}}) diff --git a/app/library/YTDLPOpts.py b/app/library/YTDLPOpts.py index 77458cfd..2954165a 100644 --- a/app/library/YTDLPOpts.py +++ b/app/library/YTDLPOpts.py @@ -2,11 +2,11 @@ import logging from pathlib import Path from .config import Config -from .Presets import Presets +from .Presets import Presets, Preset from .Singleton import Singleton from .Utils import REMOVE_KEYS, arg_converter, calc_download_path, load_cookies, merge_dict -LOG = logging.getLogger("YTDLPOpts") +LOG: logging.Logger = logging.getLogger("YTDLPOpts") class YTDLPOpts(metaclass=Singleton): @@ -79,11 +79,11 @@ class YTDLPOpts(metaclass=Singleton): YTDLPOpts: The instance of the class """ - bad_options = {} + bad_options: dict = {} if from_user: - bad_options = {k: v for d in REMOVE_KEYS for k, v in d.items()} + bad_options: dict[str, str] = {k: v for d in REMOVE_KEYS for k, v in d.items()} - removed_options = [] + removed_options: list = [] for key, value in config.items(): if from_user and key in bad_options: @@ -97,19 +97,18 @@ class YTDLPOpts(metaclass=Singleton): return self - def preset(self, name: str, with_cookies: bool = False) -> "YTDLPOpts": + def preset(self, name: str) -> "YTDLPOpts": """ Add the preset options to the item options. Args: name (str): The name of the preset - with_cookies (bool): If the cookies should be added Returns: YTDLPOpts: The instance of the class """ - preset = Presets.get_instance().get(name) + preset: Preset | None = Presets.get_instance().get(name) if not preset or "default" == name: return self @@ -122,25 +121,26 @@ class YTDLPOpts(metaclass=Singleton): msg = f"Invalid preset '{preset.name}' command options for yt-dlp. '{e!s}'." raise ValueError(msg) from e - if preset.cookies and with_cookies: - file = Path(self._config.config_path, "cookies", f"{preset.id}.txt") + if preset.cookies: + file: Path = Path(self._config.config_path) / "cookies" / f"{preset.id}.txt" if not file.parent.exists(): - file.parent.mkdir(parents=True) + file.parent.mkdir(parents=True, exist_ok=True) - with open(file, "w") as f: - f.write(preset.cookies) + file.write_text(preset.cookies) - load_cookies(str(file)) - - self._preset_opts["cookiefile"] = str(file) + try: + load_cookies(file) + self._preset_opts["cookiefile"] = str(file) + except ValueError as e: + LOG.error(str(e)) if preset.template: self._preset_opts["outtmpl"] = {"default": preset.template, "chapter": self._config.output_template_chapter} if preset.folder: self._preset_opts["paths"] = { - "home": calc_download_path(base_path=self._config.download_path, folder=preset.folder), + "home": calc_download_path(base_path=Path(self._config.download_path), folder=preset.folder), "temp": self._config.temp_path, } @@ -157,7 +157,7 @@ class YTDLPOpts(metaclass=Singleton): dict: The options """ - default_opts = {} + default_opts: dict = {} default_opts["paths"] = {"home": self._config.download_path, "temp": self._config.temp_path} default_opts["outtmpl"] = { "default": self._config.output_template, @@ -167,7 +167,7 @@ class YTDLPOpts(metaclass=Singleton): if not isinstance(self._item_cli, list): self._item_cli = [] - merge = [] + merge: list[str] = [] if self._config._ytdlp_cli_mutable and len(self._config._ytdlp_cli_mutable) > 1: merge.append(self._config._ytdlp_cli_mutable) @@ -178,12 +178,16 @@ class YTDLPOpts(metaclass=Singleton): # prepend the yt-dlp command options to the list self._item_cli = merge + self._item_cli - user_cli = {} + user_cli: dict = {} if len(self._item_cli) > 0: try: - removed_options = [] - user_cli = arg_converter(args="\n".join(self._item_cli), level=True, removed_options=removed_options) + removed_options: list = [] + user_cli: dict = arg_converter( + args="\n".join(self._item_cli), + level=True, + removed_options=removed_options, + ) if len(removed_options) > 0: LOG.warning("Removed the following options: '%s'.", ", ".join(removed_options)) diff --git a/app/library/conditions.py b/app/library/conditions.py index eff44d6f..f9ee2ee4 100644 --- a/app/library/conditions.py +++ b/app/library/conditions.py @@ -1,8 +1,8 @@ import json import logging -import os import uuid from dataclasses import dataclass, field +from pathlib import Path from typing import Any from aiohttp import web @@ -11,9 +11,9 @@ from .config import Config from .encoder import Encoder from .Events import EventBus, Events from .Singleton import Singleton -from .Utils import arg_converter +from .Utils import arg_converter, init_class -LOG = logging.getLogger("presets") +LOG = logging.getLogger("conditions") @dataclass(kw_only=True) @@ -51,16 +51,16 @@ class Conditions(metaclass=Singleton): _instance = None """The instance of the class.""" - def __init__(self, file: str | None = None, config: Config | None = None): + def __init__(self, file: Path | str | None = None, config: Config | None = None): Conditions._instance = self config = config or Config.get_instance() - self._file: str = file or os.path.join(config.config_path, "conditions.json") + self._file: Path = Path(file) if file else Path(config.config_path) / "conditions.json" - if os.path.exists(self._file) and "600" != oct(os.stat(self._file).st_mode)[-3:]: + if self._file.exists() and "600" != self._file.stat().st_mode: try: - os.chmod(self._file, 0o600) + self._file.chmod(0o600) except Exception: pass @@ -68,7 +68,7 @@ class Conditions(metaclass=Singleton): msg = "Not implemented" raise Exception(msg) - EventBus.get_instance().subscribe(Events.CONDITIONS_ADD, event_handler, f"{__class__.__name__}.add") + EventBus.get_instance().subscribe(Events.CONDITIONS_ADD, event_handler, f"{__class__.__name__}.save") @staticmethod def get_instance() -> "Conditions": @@ -114,15 +114,15 @@ class Conditions(metaclass=Singleton): """ self.clear() - if not os.path.exists(self._file) or os.path.getsize(self._file) < 10: + if not self._file.exists() or self._file.stat().st_size < 1: return self - LOG.info(f"Loading '{self._file}'.") try: - with open(self._file) as f: - items = json.load(f) + LOG.info(f"Loading '{self._file}'.") + items = json.loads(self._file.read_text()) except Exception as e: - LOG.error(f"Failed to parse '{self._file}'. '{e}'.") + LOG.exception(e) + LOG.error(f"Error loading '{self._file}'. '{e}'.") return self if not items or len(items) < 1: @@ -137,15 +137,15 @@ class Conditions(metaclass=Singleton): item["id"] = str(uuid.uuid4()) need_save = True - item = Condition(**item) + item: Condition = init_class(Condition, item) self._items.append(item) except Exception as e: - LOG.error(f"Failed to parse failure condition at list position '{i}'. '{e!s}'.") + LOG.error(f"Failed to parse condition at list position '{i}'. '{e!s}'.") continue if need_save: - LOG.info("Saving failure conditions due to format, or id change.") + LOG.info("Saving conditions due to format, or id change.") self.save(self._items) return self @@ -229,7 +229,7 @@ class Conditions(metaclass=Singleton): for i, item in enumerate(items): try: if not isinstance(item, Condition): - item = Condition(**item) + item: Condition = init_class(Condition, item) items[i] = item except Exception as e: LOG.error(f"Failed to save '{i}' due to parsing error. '{e!s}'.") @@ -242,12 +242,10 @@ class Conditions(metaclass=Singleton): continue try: - with open(self._file, "w") as f: - json.dump(obj=[item.serialize() for item in items], fp=f, indent=4) - + self._file.write_text(json.dumps(obj=[item.serialize() for item in items], indent=4)) LOG.info(f"Updated '{self._file}'.") except Exception as e: - LOG.error(f"Failed to save '{self._file}'. '{e!s}'.") + LOG.error(f"Error saving '{self._file}'. '{e!s}'.") return self diff --git a/app/library/config.py b/app/library/config.py index 195b7b0d..bf3ab7da 100644 --- a/app/library/config.py +++ b/app/library/config.py @@ -11,11 +11,14 @@ from pathlib import Path import coloredlogs from dotenv import load_dotenv -from .Utils import FileLogFormatter, arg_converter, load_cookies +from .Utils import FileLogFormatter, arg_converter from .version import APP_VERSION class Config: + app_env: str = "production" + """The application environment, can be 'production' or 'development'.""" + config_path: str = "." """The path to the configuration directory.""" @@ -152,10 +155,13 @@ class Config: _ytdlp_cli_mutable: str = "" """The command line options to use for yt-dlp.""" + is_native: bool = False + "Is the application running in webview." + pictures_backends: list[str] = [ "https://unsplash.it/1920/1080?random", "https://picsum.photos/1920/1080", - "https://placedog.net/1920/1080", + "https://www.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1&mkt=en-US", ] "The list of picture backends to use for the background." @@ -175,6 +181,7 @@ class Config: "started", "ytdlp_cli", "_ytdlp_cli_mutable", + "is_native", ) "The variables that are immutable." @@ -223,6 +230,8 @@ class Config: "ytdlp_cli", "file_logging", "base_path", + "is_native", + "app_env", ) "The variables that are relevant to the frontend." @@ -230,9 +239,9 @@ class Config: "The manager instance." @staticmethod - def get_instance(): + def get_instance(is_native: bool = False) -> "Config": """Static access method.""" - return Config() if not Config.__instance else Config.__instance + return Config(is_native) if not Config.__instance else Config.__instance @staticmethod def get_manager() -> SyncManager: @@ -241,7 +250,7 @@ class Config: return Config._manager - def __init__(self): + def __init__(self, is_native: bool = False): """Virtually private constructor.""" if Config.__instance is not None: msg = "This class is a singleton. Use Config.get_instance() instead." @@ -251,15 +260,16 @@ class Config: baseDefaultPath: str = str(Path(__file__).parent.parent.parent.absolute()) - self.temp_path = os.environ.get("YTP_TEMP_PATH", None) or os.path.join(baseDefaultPath, "var", "tmp") - self.config_path = os.environ.get("YTP_CONFIG_PATH", None) or os.path.join(baseDefaultPath, "var", "config") - self.download_path = os.environ.get("YTP_DOWNLOAD_PATH", None) or os.path.join( - baseDefaultPath, "var", "downloads" + self.is_native = is_native + self.temp_path = os.environ.get("YTP_TEMP_PATH", None) or str(Path(baseDefaultPath) / "var" / "tmp") + self.config_path = os.environ.get("YTP_CONFIG_PATH", None) or str(Path(baseDefaultPath) / "var" / "config") + self.download_path = os.environ.get("YTP_DOWNLOAD_PATH", None) or str( + Path(baseDefaultPath) / "var" / "downloads" ) - envFile: str = os.path.join(self.config_path, ".env") + envFile: str = Path(self.config_path) / ".env" - if os.path.exists(envFile): + if envFile.exists(): logging.info(f"Loading environment variables from '{envFile}'.") load_dotenv(envFile) @@ -326,8 +336,8 @@ class Config: LOG.error(f"Error starting debugpy server at '0.0.0.0:{self.debugpy_port}'. {e}") ytdl_options = {} - opts_file: str = os.path.join(self.config_path, "ytdlp.cli") - if os.path.exists(opts_file) and os.path.getsize(opts_file) > 2: + opts_file: Path = Path(self.config_path) / "ytdlp.cli" + if opts_file.exists() and opts_file.stat().st_size > 2: LOG.info(f"Loading yt-dlp custom options from '{opts_file}'.") with open(opts_file) as f: self.ytdlp_cli = f.read().strip() @@ -357,25 +367,13 @@ class Config: self._ytdlp_cli_mutable += f"\n--socket-timeout {self.socket_timeout}" if self.keep_archive: - LOG.info("keep archive option is enabled.") - archive_file: str = os.path.join(self.config_path, "archive.log") - self._ytdlp_cli_mutable += f"\n--download-archive {archive_file}" + archive_file: Path = Path(self.config_path) / "archive.log" + if not archive_file.exists(): + LOG.info(f"Creating archive file '{archive_file}'.") + archive_file.touch(exist_ok=True) - if cookies_file := ytdl_options.get("cookiefile", None): - if os.path.exists(cookies_file) and os.path.getsize(cookies_file) > 2: - LOG.info(f"Using cookies from '{cookies_file}'.") - load_cookies(cookies_file) - self._ytdlp_cli_mutable += f"\n--cookies {cookies_file}" - else: - LOG.warning(f"Invalid cookie file '{cookies_file}' specified.") - ytdl_options.pop("cookiefile", None) - - if not ytdl_options.get("cookiefile", None): - cookies_file: str = os.path.join(self.config_path, "cookies.txt") - if os.path.exists(cookies_file) and os.path.getsize(cookies_file) > 2: - LOG.info(f"Using cookies from '{cookies_file}'.") - load_cookies(cookies_file) - self._ytdlp_cli_mutable += f"\n--cookies {cookies_file}" + LOG.info(f"keep archive option is enabled. Using archive file '{archive_file}'.") + self._ytdlp_cli_mutable += f"\n--download-archive {archive_file.as_posix()!s}" if self.temp_keep: LOG.info("Keep temp files option is enabled.") @@ -392,23 +390,24 @@ class Config: msg = f"Invalid file log level '{self.log_level_file}' specified." raise TypeError(msg) - loggingPath = os.path.join(self.config_path, "logs") - if not os.path.exists(loggingPath): - os.makedirs(loggingPath, exist_ok=True) + loggingPath = Path(self.config_path) / "logs" + if not loggingPath.exists(): + loggingPath.mkdir(parents=True, exist_ok=True) handler = TimedRotatingFileHandler( - filename=os.path.join(self.config_path, "logs", "app.log"), + filename=loggingPath / "app.log", when="midnight", backupCount=3, ) + handler.setLevel(log_level_file) formatter = FileLogFormatter("%(asctime)s [%(levelname)s.%(name)s]: %(message)s") handler.setFormatter(formatter) logging.getLogger().addHandler(handler) - key_file: str = os.path.join(self.config_path, "secret.key") + key_file: str = Path(self.config_path) / "secret.key" - if os.path.exists(key_file) and os.path.getsize(key_file) > 5: + if key_file.exists() and key_file.stat().st_size > 2: with open(key_file, "rb") as f: self.secret_key = f.read().strip() else: @@ -421,6 +420,16 @@ class Config: logging.getLogger("httpx").setLevel(logging.WARNING) logging.getLogger("httpcore").setLevel(logging.INFO) + # check env + if self.app_env not in ("production", "development"): + msg: str = ( + f"Invalid application environment '{self.app_env}' specified. Must be 'production' or 'development'." + ) + raise ValueError(msg) + + if self.version == "dev-master": + self._version_via_git() + def _get_attributes(self) -> dict: attrs: dict = {} vClass: str = self.__class__ @@ -435,6 +444,26 @@ class Config: return attrs + def is_dev(self) -> bool: + """ + Check if the application is running in development mode. + + Returns: + bool: True if the application is in development mode, False otherwise. + + """ + return "development" == self.app_env + + def is_prod(self) -> bool: + """ + Check if the application is running in production mode. + + Returns: + bool: True if the application is in production mode, False otherwise. + + """ + return "production" == self.app_env + def get_ytdlp_args(self) -> dict: try: return arg_converter(args=self._ytdlp_cli_mutable, level=True) @@ -454,9 +483,6 @@ class Config: ytdlp_args = self.get_ytdlp_args() - hasCookies = ytdlp_args.get("cookiefile", None) - data["has_cookies"] = hasCookies is not None and os.path.exists(hasCookies) - if not data.get("keep_archive", False) and ytdlp_args.get("download_archive", None): data["keep_archive"] = True @@ -471,3 +497,59 @@ class Config: return YTDLP_VERSION except ImportError: return "0.0.0" + + def _version_via_git(self): + """ + Updates the version of the application using git tags. + This is used to set the version to the latest git tag. + """ + git_path: Path = Path(__file__).parent / ".." / ".." / ".git" + if not git_path.exists(): + logging.info(f"Git directory '{git_path}' does not exist. Cannot determine version.") + return + + try: + import subprocess + + branch_result = subprocess.run( # noqa: S603 + ["git", "rev-parse", "--abbrev-ref", "HEAD"], # noqa: S607 + cwd=git_path.parent, + capture_output=True, + text=True, + check=False, + ) + + if 0 != branch_result.returncode: + logging.error(f"Git rev-parse failed: {branch_result.stderr.strip()}") + return + + branch_name: str = branch_result.stdout.strip() + if not branch_name: + logging.warning("Git branch name is empty.") + return + + commit_result = subprocess.run( # noqa: S603 + ["git", "log", "-1", "--format=%ct_%H"], # noqa: S607 + cwd=git_path.parent, + capture_output=True, + text=True, + check=False, + ) + + if 0 != commit_result.returncode: + logging.error(f"Git log failed: {commit_result.stderr.strip()}") + return + + commit_info: str = commit_result.stdout.strip() + if not commit_info: + logging.warning("Git commit info is empty.") + return + + commit_date, commit_sha = commit_info.split("_", 1) + commit_date = time.strftime("%Y%m%d", time.localtime(int(commit_date))) + commit_sha = commit_sha[:8] + + self.version = f"{branch_name}-{commit_date}-{commit_sha}" + logging.info(f"Application version set to '{self.version}' based on git data.") + except Exception as e: + logging.error(f"Error while getting git version: {e!s}") diff --git a/app/library/ffprobe.py b/app/library/ffprobe.py index b38da7a7..3475aea2 100644 --- a/app/library/ffprobe.py +++ b/app/library/ffprobe.py @@ -7,6 +7,7 @@ import functools import json import operator import os +from pathlib import Path import anyio @@ -271,7 +272,7 @@ async def ffprobe(file: str) -> FFProbeResult: msg = "ffprobe not found." raise OSError(msg) from e - if not os.path.isfile(file): + if not Path(file).exists(): msg = f"No such media file '{file}'." raise OSError(msg) @@ -290,7 +291,7 @@ async def ffprobe(file: str) -> FFProbeResult: if 0 == exitCode: parsed: dict = json.loads(data.decode("utf-8")) else: - msg = f"FFProbe return with non-0 exit code. '{err.decode('utf-8')}'" + msg = f"ffprobe returned with non-0 exit code. '{err.decode('utf-8')}'" raise FFProbeError(msg) result = FFProbeResult() diff --git a/app/library/presets.json b/app/library/presets.json deleted file mode 100644 index caf82fcf..00000000 --- a/app/library/presets.json +++ /dev/null @@ -1,62 +0,0 @@ -[ - { - "id": "3e163c6c-64eb-4448-924f-814b629b3810", - "name": "default", - "description": "", - "folder": "", - "template": "", - "cookies": "", - "cli": "", - "default": true - }, - { - "id": "5bf9c42b-8852-468a-99f5-915622dfba25", - "name": "Best video and audio", - "description": "", - "folder": "", - "template": "", - "cookies": "", - "cli": "--format 'bv+ba/b'", - "default": true - }, - { - "id": "441675ed-b739-40f0-a0b0-1ecfcb9dc48b", - "name": "1080p H264/m4a or best available", - "description": "", - "folder": "", - "template": "", - "cookies": "", - "cli": "-S vcodec:h264 --format 'bv[height<=1080][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]'", - "default": true - }, - { - "id": "9719fcc3-4cf2-4d88-b1e4-74dff3dba00e", - "name": "720p h264/m4a or best available", - "description": "", - "folder": "", - "template": "", - "cookies": "", - "cli": "-S vcodec:h264 --format 'bv[height<=720][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]'", - "default": true - }, - { - "id": "a6fd4b25-2b3e-458d-bb57-b75e41cc4330", - "name": "Audio only", - "description": "", - "folder": "", - "template": "", - "cookies": "", - "cli": "--extract-audio --add-chapters --embed-metadata --embed-thumbnail --format 'bestaudio/best'", - "default": true - }, - { - "id": "2ade2c28-cad4-4a06-b7eb-2439fdf46f60", - "name": "ytdlp-info-reader", - "description": "This preset generate specific filename format and metadata to work with yt-dlp info reader plugins for jellyfin/emby and plex and to make play state sync work for WatchState.\n\nThere is one more step you need to do via Other > Terminal if you have it enabled or directly from container shell\n\nyt-dlp -I0 --write-info-json --write-thumbnail --convert-thumbnails jpg --paths /downloads/youtube -o \"%(channel|Unknown_title)s [%(channel_id|Unknown_id)s]/%(title).180B [%(channel_id|Unknown_id)s].%(ext)s\" -- https://www.youtube.com/channel/UCClfFsWcT3N2I7VTXXyt84A\n\nChange the url to the channel you want to download.\n\nFor more information please visit \nhttps://github.com/arabcoders/watchstate/blob/master/FAQ.md#how-to-get-watchstate-working-with-youtube-contentlibrary", - "folder": "youtube", - "template": "%(channel)s %(channel_id|Unknown_id)s/Season %(release_date>%Y,upload_date>%Y|Unknown)s/%(release_date>%Y%m%d,upload_date>%Y%m%d)s - %(title).180B [%(extractor)s-%(id)s].%(ext)s", - "cookies": "", - "cli": "--windows-filenames --write-info-json --embed-info-json \n--convert-thumbnails jpg --write-thumbnail --embed-metadata", - "default": true - } -] diff --git a/app/main.py b/app/main.py index 2880aa36..1b646740 100644 --- a/app/main.py +++ b/app/main.py @@ -2,8 +2,8 @@ import asyncio import logging -import os import sqlite3 +import sys from pathlib import Path import caribou @@ -23,16 +23,22 @@ from library.Tasks import Tasks LOG = logging.getLogger("app") MIME = magic.Magic(mime=True) +ROOT_PATH: Path = Path(__file__).parent.absolute() + class Main: - def __init__(self): - self._config = Config.get_instance() - self.rootPath = str(Path(__file__).parent.absolute()) + def __init__(self, is_native: bool = False): + self._config = Config.get_instance(is_native=is_native) self._app = web.Application() + if self._config.debug: + loop = asyncio.get_event_loop() + loop.set_debug(True) + loop.slow_callback_duration = 0.05 + self._check_folders() - caribou.upgrade(self._config.db_file, os.path.join(self.rootPath, "migrations")) + caribou.upgrade(self._config.db_file, ROOT_PATH / "migrations") connection = sqlite3.connect(database=self._config.db_file, isolation_level=None) connection.row_factory = sqlite3.Row @@ -44,13 +50,14 @@ class Main: LOG.debug("Database connection closed.") try: - if "600" != oct(os.stat(self._config.db_file).st_mode)[-3:]: - os.chmod(self._config.db_file, 0o600) + db_file = Path(self._config.db_file) + if "600" != oct(db_file.stat().st_mode)[-3:]: + db_file.chmod(0o600) except Exception: pass self._queue = DownloadQueue(connection=connection) - self._http = HttpAPI(queue=self._queue) + self._http = HttpAPI(root_path=ROOT_PATH, queue=self._queue) self._socket = HttpSocket(queue=self._queue) self._app.on_cleanup.append(_close_connection) @@ -60,31 +67,34 @@ class Main: folders = (self._config.download_path, self._config.temp_path, self._config.config_path) for folder in folders: + folder = Path(folder) try: LOG.debug(f"Checking folder at '{folder}'.") - if not os.path.exists(folder): + if not folder.exists(): LOG.info(f"Creating folder at '{folder}'.") - os.makedirs(folder, exist_ok=True) + folder.mkdir(parents=True, exist_ok=True) except OSError: LOG.error(f"Could not create folder at '{folder}'.") raise try: - LOG.debug(f"Checking database file at '{self._config.db_file}'.") - if not os.path.exists(self._config.db_file): - LOG.info(f"Creating database file at '{self._config.db_file}'.") - with open(self._config.db_file, "w") as _: - pass - except OSError: - LOG.error(f"Could not create database file at '{self._config.db_file}'.") + db_file = Path(self._config.db_file) + LOG.debug(f"Checking database file at '{db_file}'.") + if not db_file.exists(): + LOG.info(f"Creating database file at '{db_file}'.") + db_file.touch(exist_ok=True) + except OSError as e: + LOG.error(f"Could not create database file at '{self._config.db_file}'. {e!s}") raise - def start(self): + def start(self, host: str | None = None, port: int | None = None, cb=None): """ Start the application. """ - EventBus.get_instance().sync_emit(Events.STARTUP, data={"app": self._app}) + host = host or self._config.host + port = port or self._config.port + EventBus.get_instance().sync_emit(Events.STARTUP, data={"app": self._app}) Scheduler.get_instance().attach(self._app) self._socket.attach(self._app) @@ -100,10 +110,10 @@ class Main: def started(_): LOG.info("=" * 40) - LOG.info( - f"YTPTube v{self._config.version} - started on http://{self._config.host}:{self._config.port}{self._config.base_path}" - ) + LOG.info(f"YTPTube v{self._config.version} - started on http://{host}:{port}{self._config.base_path}") LOG.info("=" * 40) + if cb: + cb() HTTP_LOGGER = None if self._config.access_log: @@ -113,12 +123,13 @@ class Main: web.run_app( self._app, - host=self._config.host, - port=self._config.port, - reuse_port=True, + host=host, + port=port, + reuse_port="win32" != sys.platform, loop=asyncio.get_event_loop(), access_log=HTTP_LOGGER, print=started, + handle_signals=cb is None, ) diff --git a/app/native.py b/app/native.py new file mode 100644 index 00000000..120b2b0a --- /dev/null +++ b/app/native.py @@ -0,0 +1,114 @@ +import json +import os +import queue +import socket +import threading +from pathlib import Path + +ready = threading.Event() +exception_holder = queue.Queue() + +APP_NAME = "YTPTube" + +try: + import webview # type: ignore +except ImportError as e: + msg = "Please run 'pipenv install pywebview[qt]' package to run YTPTube in native mode." + raise ImportError(msg) from e + + +def set_env(): + import platformdirs + + dct = {} + + if not os.getenv("YTP_CONFIG_PATH"): + dct["YTP_CONFIG_PATH"] = platformdirs.user_config_dir(APP_NAME.lower(), "arabcoders", ensure_exists=True) + + if not os.getenv("YTP_TEMP_PATH"): + dct["YTP_TEMP_PATH"] = platformdirs.user_cache_dir(APP_NAME.lower(), "arabcoders", ensure_exists=True) + + if not os.getenv("YTP_DOWNLOAD_PATH"): + dct["YTP_DOWNLOAD_PATH"] = platformdirs.user_downloads_dir() + + if os.getenv("YTP_ACCESS_LOG", None) is None: + dct["YTP_ACCESS_LOG"] = "false" + + if dct: + os.environ.update(dct) + + +def app_start(host: str, port: int) -> None: + import asyncio + + from main import Main + + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + Main(is_native=True).start(host, port, cb=lambda: ready.set()) + except Exception as e: + exception_holder.put(e) + ready.set() + + +if __name__ == "__main__": + host = "127.0.0.1" + set_env() + + cfg_path: Path = Path(os.getenv("YTP_CONFIG_PATH")) / "webview.json" + + port = None + win_conf: dict[str, int] = {} + if cfg_path.exists(): + data = json.loads(cfg_path.read_text()) + port = data.get("port") + for key in ("width", "height", "x", "y"): + if key in data: + win_conf[key] = data[key] + + if not port: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind((host, 0)) + port = s.getsockname()[1] + cfg_path.write_text(json.dumps({"port": port})) + + threading.Thread(target=app_start, args=(host, port), daemon=True).start() + + ready.wait() + + if not exception_holder.empty(): + raise exception_holder.get() + + create_kwargs = {**win_conf, "resizable": True} + + webview.settings["ALLOW_DOWNLOADS"] = True + webview.settings["OPEN_DEVTOOLS_IN_DEBUG"] = False + window = webview.create_window(APP_NAME, f"http://{host}:{port}", **create_kwargs) + + def save_geometry(): + cfg = { + "port": port, + "width": window.width, + "height": window.height, + "x": window.x, + "y": window.y, + } + cfg_path.write_text(json.dumps(cfg)) + + window.events.resized += lambda *_: save_geometry() + window.events.moved += lambda *_: save_geometry() + + gui = os.getenv("YTP_WV_GUI", None) + gui = "edgechromium" if os.name == "nt" else "qt" + + webview.start( + gui=gui, + debug=True, + storage_path=str(Path(os.getenv("YTP_TEMP_PATH", os.getcwd())) / "webview"), + private_mode=False, + ) diff --git a/app/upgrader.py b/app/upgrader.py index c06e9217..0eade685 100644 --- a/app/upgrader.py +++ b/app/upgrader.py @@ -12,31 +12,18 @@ LOG = logging.getLogger("upgrader") class Upgrader: def __init__(self): - import argparse + config_path: Path = Path(__file__).parent.parent / "var" / "config" + if env_path := os.environ.get("YTP_CONFIG_PATH", None): + config_path = Path(env_path) - parser = argparse.ArgumentParser( - prog="upgrader.py", - formatter_class=argparse.ArgumentDefaultsHelpFormatter, - description="Upgrade packages and run the application.", - epilog="Example: upgrader.py --run", - ) - - parser.add_argument( - "-r", "--run", action="store_true", help="Run the application after upgrading the packages." - ) - - args, _ = parser.parse_known_args() - - rootPath = str(Path(__file__).parent.parent.absolute()) - config_path = os.environ.get("YTP_CONFIG_PATH", None) or os.path.join(rootPath, "var", "config") - if not Path(config_path).exists(): + if config_path.exists(): + envFile: Path = config_path / ".env" + if envFile.exists(): + LOG.debug(f"loading environment variables from '{envFile}'.") + load_dotenv(str(envFile)) + else: LOG.error(f"config path '{config_path}' doesn't exists.") - envFile = Path(config_path, ".env") - if envFile.exists(): - LOG.debug(f"loading environment variables from '{envFile}'.") - load_dotenv(str(envFile)) - pkg_installer = PackageInstaller() ytdlp_auto_update: bool = os.environ.get("YTP_YTDLP_AUTO_UPDATE", "true").strip().lower() == "true" @@ -65,11 +52,6 @@ class Upgrader: LOG.exception(e) LOG.error(f"Failed to check for packages. '{e!s}'.") - if args.run: - from main import Main - - Main().start() - if __name__ == "__main__": try: diff --git a/entrypoint.sh b/entrypoint.sh index 9a341cf9..85246d8d 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -11,11 +11,11 @@ if [ ! -w "${YTP_CONFIG_PATH}" ]; then CH_GRP=$(stat -c "%g" "${YTP_CONFIG_PATH}") echo_err "ERROR: Unable to write to '${YTP_CONFIG_PATH}' data directory. Current user id '${UID}' while directory owner is '${CH_USER}'." echo_err "[Running under docker]" - echo_err "change docker-compose.yaml user: to user:\"${CH_USER}:${CH_GRP}\"" + echo_err "change compose.yaml user: to user:\"${CH_USER}:${CH_GRP}\"" echo_err "Run the following command to change the directory ownership" echo_err "chown -R \"${CH_USER}:${CH_GRP}\" ./config" echo_err "[Running under podman]" - echo_err "change docker-compose.yaml user: to user:\"0:0\"" + echo_err "change compose.yaml user: to user:\"0:0\"" exit 1 fi @@ -24,11 +24,11 @@ if [ ! -w "${YTP_DOWNLOAD_PATH}" ]; then CH_GRP=$(stat -c "%g" "${YTP_DOWNLOAD_PATH}") echo_err "ERROR: Unable to write to '${YTP_DOWNLOAD_PATH}' downloads directory. Current user id '${UID}' while directory owner is '${CH_USER}'." echo_err "[Running under docker]" - echo_err "change docker-compose.yaml user: to user:\"${CH_USER}:${CH_GRP}\"" + echo_err "change compose.yaml user: to user:\"${CH_USER}:${CH_GRP}\"" echo_err "Run the following command to change the directory ownership" echo_err "chown -R \"${CH_USER}:${CH_GRP}\" ./config" echo_err "[Running under podman]" - echo_err "change docker-compose.yaml user: to user:\"0:0\"" + echo_err "change compose.yaml user: to user:\"0:0\"" exit 1 fi diff --git a/healthcheck.sh b/healthcheck.sh index 7af3db58..d21acb77 100644 --- a/healthcheck.sh +++ b/healthcheck.sh @@ -2,6 +2,7 @@ set -euuo pipefail LOCAL_PORT="${YTP_PORT:-8081}" +BASE_PATH="${YTP_BASE_PATH:-/}" curl_args=(-f) @@ -10,5 +11,8 @@ if [[ -n "${YTP_AUTH_USERNAME:-}" && -n "${YTP_AUTH_PASSWORD:-}" ]]; then curl_args+=(-H "Authorization: Basic ${cred}") fi -curl_args+=("http://localhost:${LOCAL_PORT}/api/ping") +# strip trailing slashes from BASE_PATH +BASE_PATH=$(echo "${BASE_PATH}" | sed 's:/*$::') + +curl_args+=("http://localhost:${LOCAL_PORT}${BASE_PATH}/api/ping") /usr/bin/curl "${curl_args[@]}" diff --git a/pyproject.toml b/pyproject.toml index 54cc4013..502e460e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,42 @@ +[project] +name = "ytptube" +version = "1.0.0" +description = "A WebUI for yt-dlp with concurrent downloads support, presets and scheduled tasks and many more." +readme = "README.md" +requires-python = ">=3.11" +license = { text = "MIT" } +authors = [{ name = "Abdulmohsen", email = "contact@arabcoders.org" }] +dependencies = [ + # Windows‐only + "tzdata; sys_platform == 'win32'", + "python-magic-bin; sys_platform == 'win32'", + # Non‐Windows + "python-magic>=0.4.27; sys_platform != 'win32'", + + # Cross‐platform + "python-socketio>=5.11.1", + "aiohttp>=3.9.3", + "caribou>=0.3.0", + "coloredlogs>=15.0.1", + "aiocron>=1.8", + "python-dotenv>=1.0.1", + "debugpy>=1.8.1", + "httpx", + "async-timeout", + "pyjson5", + "curl_cffi==0.7.1", + "pysubs2", + "regex", + "mutagen", + "brotli", + "brotlicffi", + "anyio", + "pycryptodome", + "yt-dlp", + "platformdirs", +] + [tool.ruff] -# Exclude a variety of commonly ignored directories. exclude = [ ".bzr", ".direnv", @@ -26,7 +63,6 @@ exclude = [ "node_modules", "site-packages", "venv", - ".venv", ] # Same as Black. @@ -36,6 +72,16 @@ indent-width = 4 # Assume Python 3.11 target-version = "py311" +[project.optional-dependencies] +webview = [ + #windows only + "qtpy; sys_platform == 'win32'", + "pyside6; sys_platform == 'win32'", + # all + "pywebview", + "pyinstaller", +] + [tool.ruff.lint] # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. # Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or @@ -109,6 +155,7 @@ ignore = [ "PLW2901", "ERA001", "S101", + "LOG015", ] # Allow fix for all enabled rules (when `--fix`) is provided. @@ -147,3 +194,6 @@ docstring-code-line-length = "dynamic" [tool.autopep8] --max-line-length = 120 + +[tool.uv] +link-mode = "copy" diff --git a/ui/components/ConditionForm.vue b/ui/components/ConditionForm.vue index 49afd01e..a39dbc3c 100644 --- a/ui/components/ConditionForm.vue +++ b/ui/components/ConditionForm.vue @@ -71,6 +71,11 @@
- - The yt-dlp [--match-filters] filter logic. - - Test filter logic - - + yt-dlp [--match-filters] logic.
diff --git a/ui/components/Queue.vue b/ui/components/Queue.vue index eff156c8..9baaa87b 100644 --- a/ui/components/Queue.vue +++ b/ui/components/Queue.vue @@ -66,6 +66,9 @@ +
+ {{ formatBytes(item.downloaded_bytes) }} +
@@ -180,6 +183,10 @@
+
+ {{ formatBytes(item.downloaded_bytes) }} +
-