From 20ac5ef807722361b15f2514d3be6a776aac4cc7 Mon Sep 17 00:00:00 2001 From: DySprozin Date: Fri, 28 Nov 2025 09:13:36 +0300 Subject: [PATCH 01/29] Add support for channel-specific filtering in subtitle search (#1096) * Add channel filter support to subtitle search queries * Update search examples to include channel filter option --------- Co-authored-by: Dmitry Sprozin --- backend/common/src/searching.py | 17 +++++++++++++++++ .../src/components/SearchExampleQueries.tsx | 4 ++++ 2 files changed, 21 insertions(+) diff --git a/backend/common/src/searching.py b/backend/common/src/searching.py index 17c164b1..44b75d0b 100644 --- a/backend/common/src/searching.py +++ b/backend/common/src/searching.py @@ -113,6 +113,7 @@ class SearchParser: "index": "ta_subtitle", "lang": [], "source": [], + "channel": [], }, } @@ -345,6 +346,22 @@ class QueryBuilder: """build query for fulltext search""" must_list = [] + if (channel := self.query_map.get("channel")) is not None: + must_list.append( + { + "multi_match": { + "query": channel, + "type": "bool_prefix", + "fuzziness": self._get_fuzzy(), + "operator": "and", + "fields": [ + "subtitle_channel", + "subtitle_channel.keyword", + ], + } + } + ) + if (term := self.query_map.get("term")) is not None: must_list.append( { diff --git a/frontend/src/components/SearchExampleQueries.tsx b/frontend/src/components/SearchExampleQueries.tsx index 1c181c6e..a4822b61 100644 --- a/frontend/src/components/SearchExampleQueries.tsx +++ b/frontend/src/components/SearchExampleQueries.tsx @@ -104,6 +104,10 @@ const SearchExampleQueries = () => { auto-generated subtitles only, or user to search through user-uploaded subtitles only +
  • + channel: — limit subtitle search to a specific channel name (for + example: full:javascript channel:corey schafer) +
  • From 877e7d0cf850b3f746fe6a3abab294d17cc9dbdf Mon Sep 17 00:00:00 2001 From: MerlinScheurer Date: Mon, 8 Dec 2025 23:12:56 +0100 Subject: [PATCH 02/29] Update frontend dependencies --- frontend/package-lock.json | 997 +++++++++++++++++++++++-------------- frontend/package.json | 34 +- 2 files changed, 641 insertions(+), 390 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7267a7c2..aea629f1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,28 +8,28 @@ "name": "tubearchivist-frontend", "version": "0.5.1", "dependencies": { - "dompurify": "^3.2.6", - "react": "^19.1.1", - "react-dom": "^19.1.1", - "react-router-dom": "^7.8.0", - "zustand": "^5.0.7" + "dompurify": "^3.3.1", + "react": "^19.2.1", + "react-dom": "^19.2.1", + "react-router-dom": "^7.10.1", + "zustand": "^5.0.9" }, "devDependencies": { - "@types/react": "^19.1.9", - "@types/react-dom": "^19.1.7", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^8.39.0", "@typescript-eslint/parser": "^8.39.0", - "@vitejs/plugin-react-swc": "^4.0.0", - "eslint": "^9.33.0", + "@vitejs/plugin-react-swc": "^4.2.2", + "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.20", - "globals": "^16.3.0", - "prettier": "3.6.2", - "typescript": "^5.9.2", - "typescript-eslint": "^8.39.0", - "vite": ">=7.1.1", - "vite-plugin-checker": "^0.10.2" + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "prettier": "3.7.4", + "typescript": "^5.9.3", + "typescript-eslint": "^8.49.0", + "vite": ">=7.2.7", + "vite-plugin-checker": "^0.12.0" } }, "node_modules/@babel/code-frame": { @@ -47,6 +47,153 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-validator-identifier": { "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", @@ -57,6 +204,94 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -753,42 +988,54 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "dev": true, "license": "MIT", "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", "engines": { - "node": ">= 8" + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, "node_modules/@rolldown/pluginutils": { @@ -1347,14 +1594,13 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.5.tgz", - "integrity": "sha512-keKxkZMqnDicuvFoJbzrhbtdLSPhj/rZThDlKWCDbgXmUg0rEUFtRssDXKYmtXluZlIqiC5VqkCgRwzuyLHKHw==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", + "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { - "csstype": "^3.0.2" + "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { @@ -1375,18 +1621,17 @@ "optional": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.4.tgz", - "integrity": "sha512-R48VhmTJqplNyDxCyqqVkFSZIx1qX6PzwqgcXn1olLrzxcSBDlOsbtcnQuQhNtnNiJ4Xe5gREI1foajYaYU2Vg==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz", + "integrity": "sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.46.4", - "@typescript-eslint/type-utils": "8.46.4", - "@typescript-eslint/utils": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4", - "graphemer": "^1.4.0", + "@typescript-eslint/scope-manager": "8.49.0", + "@typescript-eslint/type-utils": "8.49.0", + "@typescript-eslint/utils": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" @@ -1399,23 +1644,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.46.4", + "@typescript-eslint/parser": "^8.49.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.4.tgz", - "integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.49.0.tgz", + "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.46.4", - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/typescript-estree": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4", + "@typescript-eslint/scope-manager": "8.49.0", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0", "debug": "^4.3.4" }, "engines": { @@ -1431,14 +1675,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.4.tgz", - "integrity": "sha512-nPiRSKuvtTN+no/2N1kt2tUh/HoFzeEgOm9fQ6XQk4/ApGqjx0zFIIaLJ6wooR1HIoozvj2j6vTi/1fgAz7UYQ==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.49.0.tgz", + "integrity": "sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.46.4", - "@typescript-eslint/types": "^8.46.4", + "@typescript-eslint/tsconfig-utils": "^8.49.0", + "@typescript-eslint/types": "^8.49.0", "debug": "^4.3.4" }, "engines": { @@ -1453,14 +1697,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.4.tgz", - "integrity": "sha512-tMDbLGXb1wC+McN1M6QeDx7P7c0UWO5z9CXqp7J8E+xGcJuUuevWKxuG8j41FoweS3+L41SkyKKkia16jpX7CA==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.49.0.tgz", + "integrity": "sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4" + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1471,9 +1715,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.4.tgz", - "integrity": "sha512-+/XqaZPIAk6Cjg7NWgSGe27X4zMGqrFqZ8atJsX3CWxH/jACqWnrWI68h7nHQld0y+k9eTTjb9r+KU4twLoo9A==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.49.0.tgz", + "integrity": "sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA==", "dev": true, "license": "MIT", "engines": { @@ -1488,15 +1732,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.4.tgz", - "integrity": "sha512-V4QC8h3fdT5Wro6vANk6eojqfbv5bpwHuMsBcJUJkqs2z5XnYhJzyz9Y02eUmF9u3PgXEUiOt4w4KHR3P+z0PQ==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.49.0.tgz", + "integrity": "sha512-KTExJfQ+svY8I10P4HdxKzWsvtVnsuCifU5MvXrRwoP2KOlNZ9ADNEWWsQTJgMxLzS5VLQKDjkCT/YzgsnqmZg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/typescript-estree": "8.46.4", - "@typescript-eslint/utils": "8.46.4", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0", + "@typescript-eslint/utils": "8.49.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -1513,9 +1757,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.4.tgz", - "integrity": "sha512-USjyxm3gQEePdUwJBFjjGNG18xY9A2grDVGuk7/9AkjIF1L+ZrVnwR5VAU5JXtUnBL/Nwt3H31KlRDaksnM7/w==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.49.0.tgz", + "integrity": "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==", "dev": true, "license": "MIT", "engines": { @@ -1527,21 +1771,20 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.4.tgz", - "integrity": "sha512-7oV2qEOr1d4NWNmpXLR35LvCfOkTNymY9oyW+lUHkmCno7aOmIf/hMaydnJBUTBMRCOGZh8YjkFOc8dadEoNGA==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.49.0.tgz", + "integrity": "sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.46.4", - "@typescript-eslint/tsconfig-utils": "8.46.4", - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4", + "@typescript-eslint/project-service": "8.49.0", + "@typescript-eslint/tsconfig-utils": "8.49.0", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0", "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", + "tinyglobby": "^0.2.15", "ts-api-utils": "^2.1.0" }, "engines": { @@ -1556,16 +1799,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.4.tgz", - "integrity": "sha512-AbSv11fklGXV6T28dp2Me04Uw90R2iJ30g2bgLz529Koehrmkbs1r7paFqr1vPCZi7hHwYxYtxfyQMRC8QaVSg==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.49.0.tgz", + "integrity": "sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.46.4", - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/typescript-estree": "8.46.4" + "@typescript-eslint/scope-manager": "8.49.0", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1580,13 +1823,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.4.tgz", - "integrity": "sha512-/++5CYLQqsO9HFGLI7APrxBJYo+5OCMpViuhV8q5/Qa3o5mMrF//eQHks+PXcsAVaLdn817fMuS7zqoXNNZGaw==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.49.0.tgz", + "integrity": "sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/types": "8.49.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -1633,7 +1876,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1668,19 +1910,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1711,6 +1940,16 @@ "dev": true, "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.5.tgz", + "integrity": "sha512-D5vIoztZOq1XM54LUdttJVc96ggEsIfju2JBvht06pSzpckp3C7HReun67Bghzrtdsq9XdMGbSSB3v3GhMNmAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -1721,17 +1960,38 @@ "balanced-match": "^1.0.0" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "fill-range": "^7.1.1" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" }, "engines": { - "node": ">=8" + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, "node_modules/callsites": { @@ -1744,6 +2004,27 @@ "node": ">=6" } }, + "node_modules/caniuse-lite": { + "version": "1.0.30001759", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001759.tgz", + "integrity": "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1804,13 +2085,24 @@ "dev": true, "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "license": "MIT", "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/cross-spawn": { @@ -1829,9 +2121,9 @@ } }, "node_modules/csstype": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.1.tgz", - "integrity": "sha512-98XGutrXoh75MlgLihlNxAGbUuFQc7l1cqcnEZlLNKc0UrVdPndgmaDmYTDDh929VS/eqTZV0rozmhu2qqT1/g==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "devOptional": true, "license": "MIT" }, @@ -1861,14 +2153,21 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz", - "integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1911,6 +2210,16 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -1930,7 +2239,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2002,13 +2310,20 @@ } }, "node_modules/eslint-plugin-react-hooks": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", - "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", "dev": true, "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, "engines": { - "node": ">=10" + "node": ">=18" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" @@ -2185,36 +2500,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -2229,16 +2514,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -2252,19 +2527,6 @@ "node": ">=16.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -2318,6 +2580,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2344,13 +2616,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2361,6 +2626,23 @@ "node": ">=8" } }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -2421,16 +2703,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2458,6 +2730,19 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -2479,6 +2764,19 @@ "dev": true, "license": "MIT" }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -2526,28 +2824,14 @@ "dev": true, "license": "MIT" }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" + "yallist": "^3.0.2" } }, "node_modules/minimatch": { @@ -2599,6 +2883,13 @@ "dev": true, "license": "MIT" }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, "node_modules/npm-run-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", @@ -2719,19 +3010,6 @@ "dev": true, "license": "ISC" }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -2772,9 +3050,9 @@ } }, "node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", + "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", "bin": { @@ -2797,54 +3075,31 @@ "node": ">=6" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/react": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", - "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "version": "19.2.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", + "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", - "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "version": "19.2.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", + "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.0" + "react": "^19.2.1" } }, "node_modules/react-router": { - "version": "7.9.6", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.6.tgz", - "integrity": "sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA==", + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.10.1.tgz", + "integrity": "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -2864,12 +3119,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.9.6", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.6.tgz", - "integrity": "sha512-2MkC2XSXq6HjGcihnx1s0DBWQETI4mlis4Ux7YTLvP67xnGxCvq+BcCQSO81qQHVUTM1V53tl4iVVaY5sReCOA==", + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.10.1.tgz", + "integrity": "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==", "license": "MIT", "dependencies": { - "react-router": "7.9.6" + "react-router": "7.10.1" }, "engines": { "node": ">=20.0.0" @@ -2903,17 +3158,6 @@ "node": ">=4" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rollup": { "version": "4.53.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz", @@ -2956,30 +3200,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -3038,22 +3258,6 @@ "node": ">=0.10.0" } }, - "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3128,7 +3332,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3136,19 +3339,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -3181,7 +3371,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3191,16 +3380,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.4.tgz", - "integrity": "sha512-KALyxkpYV5Ix7UhvjTwJXZv76VWsHG+NjNlt/z+a17SOQSiOcBdUXdbJdyXi7RPxrBFECtFOiPwUJQusJuCqrg==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.49.0.tgz", + "integrity": "sha512-zRSVH1WXD0uXczCXw+nsdjGPUdx4dfrs5VQoHnUWmv1U3oNlAKv4FUNdLDhVUg+gYn+a5hUESqch//Rv5wVhrg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.46.4", - "@typescript-eslint/parser": "8.46.4", - "@typescript-eslint/typescript-estree": "8.46.4", - "@typescript-eslint/utils": "8.46.4" + "@typescript-eslint/eslint-plugin": "8.49.0", + "@typescript-eslint/parser": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0", + "@typescript-eslint/utils": "8.49.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3227,6 +3416,37 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/update-browserslist-db": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", + "integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -3238,12 +3458,11 @@ } }, "node_modules/vite": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", - "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", + "version": "7.2.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz", + "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -3314,9 +3533,9 @@ } }, "node_modules/vite-plugin-checker": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/vite-plugin-checker/-/vite-plugin-checker-0.10.3.tgz", - "integrity": "sha512-f4sekUcDPF+T+GdbbE8idb1i2YplBAoH+SfRS0e/WRBWb2rYb1Jf5Pimll0Rj+3JgIYWwG2K5LtBPCXxoibkLg==", + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/vite-plugin-checker/-/vite-plugin-checker-0.12.0.tgz", + "integrity": "sha512-CmdZdDOGss7kdQwv73UyVgLPv0FVYe5czAgnmRX2oKljgEvSrODGuClaV3PDR2+3ou7N/OKGauDDBjy2MB07Rg==", "dev": true, "license": "MIT", "dependencies": { @@ -3325,22 +3544,22 @@ "npm-run-path": "^6.0.0", "picocolors": "^1.1.1", "picomatch": "^4.0.3", - "strip-ansi": "^7.1.0", "tiny-invariant": "^1.3.3", - "tinyglobby": "^0.2.14", + "tinyglobby": "^0.2.15", "vscode-uri": "^3.1.0" }, "engines": { - "node": ">=14.16" + "node": ">=16.11" }, "peerDependencies": { "@biomejs/biome": ">=1.7", - "eslint": ">=7", + "eslint": ">=9.39.1", "meow": "^13.2.0", "optionator": "^0.9.4", + "oxlint": ">=1", "stylelint": ">=16", "typescript": "*", - "vite": ">=2.0.0", + "vite": ">=5.4.21", "vls": "*", "vti": "*", "vue-tsc": "~2.2.10 || ^3.0.0" @@ -3358,6 +3577,9 @@ "optionator": { "optional": true }, + "oxlint": { + "optional": true + }, "stylelint": { "optional": true }, @@ -3412,7 +3634,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3453,6 +3674,13 @@ "node": ">=0.10.0" } }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -3466,10 +3694,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", + "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, "node_modules/zustand": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", - "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz", + "integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==", "license": "MIT", "engines": { "node": ">=12.20.0" diff --git a/frontend/package.json b/frontend/package.json index bc8a1229..eba43b05 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,27 +11,27 @@ "preview": "vite preview" }, "dependencies": { - "dompurify": "^3.2.6", - "react": "^19.1.1", - "react-dom": "^19.1.1", - "react-router-dom": "^7.8.0", - "zustand": "^5.0.7" + "dompurify": "^3.3.1", + "react": "^19.2.1", + "react-dom": "^19.2.1", + "react-router-dom": "^7.10.1", + "zustand": "^5.0.9" }, "devDependencies": { - "@types/react": "^19.1.9", - "@types/react-dom": "^19.1.7", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^8.39.0", "@typescript-eslint/parser": "^8.39.0", - "@vitejs/plugin-react-swc": "^4.0.0", - "eslint": "^9.33.0", + "@vitejs/plugin-react-swc": "^4.2.2", + "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.20", - "globals": "^16.3.0", - "prettier": "3.6.2", - "typescript": "^5.9.2", - "typescript-eslint": "^8.39.0", - "vite": ">=7.1.1", - "vite-plugin-checker": "^0.10.2" + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "prettier": "3.7.4", + "typescript": "^5.9.3", + "typescript-eslint": "^8.49.0", + "vite": ">=7.2.7", + "vite-plugin-checker": "^0.12.0" } } From 2d0253a4b8366ab4406abe303a68a5adca96c057 Mon Sep 17 00:00:00 2001 From: MerlinScheurer Date: Mon, 8 Dec 2025 23:26:47 +0100 Subject: [PATCH 03/29] Fix and ignore new Eslint react rules for now --- frontend/src/components/Filterbar.tsx | 1 + frontend/src/components/VideoPlayer.tsx | 3 +++ frontend/src/pages/Playlist.tsx | 1 - frontend/src/pages/Search.tsx | 15 ++++++++------- frontend/src/pages/SettingsApplication.tsx | 2 ++ frontend/src/pages/SettingsScheduling.tsx | 1 + 6 files changed, 15 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/Filterbar.tsx b/frontend/src/components/Filterbar.tsx index 95f3c8f9..24bc0999 100644 --- a/frontend/src/components/Filterbar.tsx +++ b/frontend/src/components/Filterbar.tsx @@ -60,6 +60,7 @@ const Filterbar = ({ } if (currentViewStyle === ViewStylesEnum.Table) { + // eslint-disable-next-line react-hooks/set-state-in-effect setShowHidden(true); } else { setShowHidden(false); diff --git a/frontend/src/components/VideoPlayer.tsx b/frontend/src/components/VideoPlayer.tsx index f0ef79d6..b4660dac 100644 --- a/frontend/src/components/VideoPlayer.tsx +++ b/frontend/src/components/VideoPlayer.tsx @@ -146,6 +146,7 @@ const VideoPlayer = ({ } if (setSeekToTimestamp) setSeekToTimestamp(undefined); window.scroll(0, 0); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [seekToTimestamp]); const [searchParams] = useSearchParams(); @@ -389,6 +390,7 @@ const VideoPlayer = ({ } else if (!theaterModePressed) { setTheaterModeKeyPressed(false); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [theaterModePressed, isTheaterMode, theaterModeKeyPressed]); useEffect(() => { @@ -401,6 +403,7 @@ const VideoPlayer = ({ infoDialog('Normal mode'); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [escapePressed, isTheaterMode]); return ( diff --git a/frontend/src/pages/Playlist.tsx b/frontend/src/pages/Playlist.tsx index 910b3205..45f2bdee 100644 --- a/frontend/src/pages/Playlist.tsx +++ b/frontend/src/pages/Playlist.tsx @@ -108,7 +108,6 @@ const Playlist = () => { } setRefresh(false); })(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [ playlistId, userConfig.hide_watched_playlist, diff --git a/frontend/src/pages/Search.tsx b/frontend/src/pages/Search.tsx index 6af56508..47fcce9a 100644 --- a/frontend/src/pages/Search.tsx +++ b/frontend/src/pages/Search.tsx @@ -65,6 +65,13 @@ const Search = () => { const gridView = isGridView ? `boxed-${gridItems}` : ''; const gridViewGrid = isGridView ? `grid-${gridItems}` : ''; + const fetchResults = async (searchQuery: string) => { + const searchResults = await loadSearch(searchQuery); + + setSearchResults(searchResults); + setRefresh(false); + }; + useEffect(() => { const handler = setTimeout(() => { setDebouncedSearchTerm(searchTerm); @@ -77,19 +84,13 @@ const Search = () => { useEffect(() => { if (debouncedSearchTerm.trim() !== '') { + // eslint-disable-next-line react-hooks/set-state-in-effect fetchResults(debouncedSearchTerm); } else { setSearchResults(EmptySearchResponse); } }, [debouncedSearchTerm, refresh, videoId]); - const fetchResults = async (searchQuery: string) => { - const searchResults = await loadSearch(searchQuery); - - setSearchResults(searchResults); - setRefresh(false); - }; - return ( <> TubeArchivist diff --git a/frontend/src/pages/SettingsApplication.tsx b/frontend/src/pages/SettingsApplication.tsx index e7ceea37..238f6610 100644 --- a/frontend/src/pages/SettingsApplication.tsx +++ b/frontend/src/pages/SettingsApplication.tsx @@ -182,11 +182,13 @@ const SettingsApplication = () => { }; useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect fetchData(); }, []); useEffect(() => { if (refresh) { + // eslint-disable-next-line react-hooks/set-state-in-effect fetchData(); setRefresh(false); } diff --git a/frontend/src/pages/SettingsScheduling.tsx b/frontend/src/pages/SettingsScheduling.tsx index 5d8084da..a5a0dd9b 100644 --- a/frontend/src/pages/SettingsScheduling.tsx +++ b/frontend/src/pages/SettingsScheduling.tsx @@ -103,6 +103,7 @@ const SettingsScheduling = () => { }, [refresh]); useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect setRefresh(true); }, []); From cceab5fe2b0fb01adb97d9f2935173d2bf4278a0 Mon Sep 17 00:00:00 2001 From: Simon Date: Sat, 13 Dec 2025 14:30:57 +0700 Subject: [PATCH 04/29] bump requirements --- backend/requirements.txt | 8 ++++---- requirements-dev.txt | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 329dd967..2b9f1a09 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,5 +1,5 @@ -apprise==1.9.5 -celery==5.5.3 +apprise==1.9.6 +celery==5.6.0 django-auth-ldap==5.2.0 django-celery-beat==2.8.1 django-cors-headers==4.9.0 @@ -7,8 +7,8 @@ Django==5.2.8 djangorestframework==3.16.1 drf-spectacular==0.29.0 Pillow==12.0.0 -redis==7.0.1 +redis==7.1.0 requests==2.32.5 ryd-client==0.0.6 uvicorn==0.38.0 -yt-dlp[default]==2025.11.12 +yt-dlp[default]==2025.12.8 diff --git a/requirements-dev.txt b/requirements-dev.txt index d03272ae..625e19b1 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,10 +1,10 @@ -r backend/requirements.txt -ipython==9.7.0 -pre-commit==4.4.0 +ipython==9.8.0 +pre-commit==4.5.0 pylint-django==2.6.1 pylint==3.3.9 pytest-django==4.11.1 -pytest==9.0.1 +pytest==9.0.2 python-dotenv==1.2.1 requirementscheck==0.1.0 types-requests==2.32.4.20250913 From 2fb58e9e94ef7d57c92ef3a68e67e9cfc90d5ef5 Mon Sep 17 00:00:00 2001 From: Simon Date: Sat, 13 Dec 2025 14:56:12 +0700 Subject: [PATCH 05/29] handle invalid timestamp and upload dates, #1094 --- backend/download/src/queue.py | 18 ++++++++++++------ backend/video/src/index.py | 2 +- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/backend/download/src/queue.py b/backend/download/src/queue.py index e03a8871..52ae114c 100644 --- a/backend/download/src/queue.py +++ b/backend/download/src/queue.py @@ -393,18 +393,24 @@ class PendingList(PendingIndex): return None @staticmethod - def _extract_published(video_data) -> str | int | None: + def _extract_published(video_data) -> int | None: """build published date or timestamp""" timestamp = video_data.get("timestamp") - if timestamp: + if timestamp and isinstance(timestamp, int): return timestamp upload_date = video_data.get("upload_date") if upload_date: - upload_date_time = datetime.strptime(upload_date, "%Y%m%d") - return upload_date_time.replace( - tzinfo=ZoneInfo(EnvironmentSettings.TZ) - ).timestamp() + try: + upload_date_time = datetime.strptime(upload_date, "%Y%m%d") + except ValueError: + youtube_id = video_data["id"] + print(f"{youtube_id}: published date extraction failed.") + return None + + tz = ZoneInfo(EnvironmentSettings.TZ) + timestamp = int(upload_date_time.replace(tzinfo=tz).timestamp()) + return timestamp return None diff --git a/backend/video/src/index.py b/backend/video/src/index.py index 0dbb2aae..1d5cf839 100644 --- a/backend/video/src/index.py +++ b/backend/video/src/index.py @@ -220,7 +220,7 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle): def _build_published(self): """build published date or timestamp""" timestamp = self.youtube_meta.get("timestamp") - if timestamp: + if timestamp and isinstance(timestamp, int): return timestamp upload_date = self.youtube_meta["upload_date"] From ac97e900472b0a3764f18b600064d201003c87e7 Mon Sep 17 00:00:00 2001 From: Simon Date: Sat, 13 Dec 2025 14:57:12 +0700 Subject: [PATCH 06/29] bump django --- backend/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 2b9f1a09..45b27c52 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,7 +3,7 @@ celery==5.6.0 django-auth-ldap==5.2.0 django-celery-beat==2.8.1 django-cors-headers==4.9.0 -Django==5.2.8 +Django==5.2.9 djangorestframework==3.16.1 drf-spectacular==0.29.0 Pillow==12.0.0 From c02ec7e82e81c5684b011eb3949a577358e31b24 Mon Sep 17 00:00:00 2001 From: Simon Date: Sat, 13 Dec 2025 15:16:12 +0700 Subject: [PATCH 07/29] validate DownloadItemSerializer for video to add to queue --- backend/download/serializers.py | 6 ++++-- backend/download/src/queue.py | 7 +++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/backend/download/serializers.py b/backend/download/serializers.py index f34a4ce7..bcaf33a9 100644 --- a/backend/download/serializers.py +++ b/backend/download/serializers.py @@ -10,13 +10,15 @@ from video.src.constants import VideoTypeEnum class DownloadItemSerializer(serializers.Serializer): """serialize download item""" - auto_start = serializers.BooleanField() + auto_start = serializers.BooleanField(required=False) channel_id = serializers.CharField() channel_indexed = serializers.BooleanField() channel_name = serializers.CharField() duration = serializers.CharField() published = serializers.CharField(allow_null=True) - status = serializers.ChoiceField(choices=["pending", "ignore"]) + status = serializers.ChoiceField( + choices=["pending", "ignore"], required=False + ) timestamp = serializers.IntegerField(allow_null=True) title = serializers.CharField() vid_thumb_url = serializers.CharField(allow_null=True) diff --git a/backend/download/src/queue.py b/backend/download/src/queue.py index 52ae114c..f372c5ea 100644 --- a/backend/download/src/queue.py +++ b/backend/download/src/queue.py @@ -20,6 +20,7 @@ from common.src.helper import ( rand_sleep, ) from common.src.urlparser import ParsedURLType +from download.serializers import DownloadItemSerializer from download.src.queue_interact import PendingInteract from download.src.thumbnails import ThumbManager from playlist.src.index import YoutubePlaylist @@ -379,6 +380,12 @@ class PendingList(PendingIndex): "channel_id": video_data["channel_id"], "channel_indexed": video_data["channel_id"] in self.all_channels, } + serializer = DownloadItemSerializer(data=to_add) + is_valid = serializer.is_valid() + if not is_valid: + print(f"{youtube_id}: serializer failed: {serializer.errors}") + self._notify_fail(403, youtube_id) + return None return to_add From afbf7ea6faf177e919392c68048d0616a76157d3 Mon Sep 17 00:00:00 2001 From: Simon Date: Sat, 13 Dec 2025 15:39:32 +0700 Subject: [PATCH 08/29] add emtadata embed for filesystem rescan and manual import --- backend/appsettings/src/filesystem.py | 1 + backend/appsettings/src/manual.py | 1 + 2 files changed, 2 insertions(+) diff --git a/backend/appsettings/src/filesystem.py b/backend/appsettings/src/filesystem.py index 1cf02cb0..0a90532f 100644 --- a/backend/appsettings/src/filesystem.py +++ b/backend/appsettings/src/filesystem.py @@ -87,6 +87,7 @@ class Scanner: progress=(idx + 1) / total, ) index_new_video(youtube_id) + YoutubeVideo(youtube_id).embed_metadata() comment_list = CommentList(task=self.task) comment_list.add(video_ids=list(self.to_index)) diff --git a/backend/appsettings/src/manual.py b/backend/appsettings/src/manual.py index 08e5b8e3..6b67a010 100644 --- a/backend/appsettings/src/manual.py +++ b/backend/appsettings/src/manual.py @@ -145,6 +145,7 @@ class ImportFolderScanner: print(f"manual import: {current_video}") ManualImport(current_video, config).run() + YoutubeVideo(current_video["video_id"]).embed_metadata() video_ids = [i["video_id"] for i in self.to_import] comment_list = CommentList(task=self.task) From 8d3b7dc3410d954a469d02a8dab81c8835ed88aa Mon Sep 17 00:00:00 2001 From: Simon Date: Sat, 13 Dec 2025 16:23:00 +0700 Subject: [PATCH 09/29] skip embed_metadata on failed import --- backend/appsettings/src/filesystem.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/appsettings/src/filesystem.py b/backend/appsettings/src/filesystem.py index 0a90532f..f481d5c9 100644 --- a/backend/appsettings/src/filesystem.py +++ b/backend/appsettings/src/filesystem.py @@ -86,8 +86,9 @@ class Scanner: ], progress=(idx + 1) / total, ) - index_new_video(youtube_id) - YoutubeVideo(youtube_id).embed_metadata() + json_data = index_new_video(youtube_id) + if json_data: + YoutubeVideo(youtube_id).embed_metadata() comment_list = CommentList(task=self.task) comment_list.add(video_ids=list(self.to_index)) From 456d0eb7983145d2b6cf6e25f409cdfcf8137598 Mon Sep 17 00:00:00 2001 From: Simon Date: Sat, 13 Dec 2025 19:07:37 +0700 Subject: [PATCH 10/29] fix comment embedding after reindex --- backend/appsettings/src/reindex.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/appsettings/src/reindex.py b/backend/appsettings/src/reindex.py index 234fd7fa..8abe5269 100644 --- a/backend/appsettings/src/reindex.py +++ b/backend/appsettings/src/reindex.py @@ -345,6 +345,7 @@ class Reindex(ReindexBase): thumb_handler.download_video_thumb(video.json_data["vid_thumb_url"]) Comments(youtube_id, config=self.config).reindex_comments() + video.get_from_es() video.embed_metadata() self.processed["videos"] += 1 From c12b061346ff0adf487de229701d496cc68153e0 Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 15 Dec 2025 20:59:15 +0700 Subject: [PATCH 11/29] downgrade drf spectacular --- backend/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 45b27c52..136e49af 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,7 +5,7 @@ django-celery-beat==2.8.1 django-cors-headers==4.9.0 Django==5.2.9 djangorestframework==3.16.1 -drf-spectacular==0.29.0 +drf-spectacular==0.28.0 # rc:ignore Pillow==12.0.0 redis==7.1.0 requests==2.32.5 From 14c47695d73edccf8702985765cece2d1f23c10c Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 15 Dec 2025 21:16:16 +0700 Subject: [PATCH 12/29] parse timestamp as str --- backend/common/src/helper.py | 2 ++ backend/common/tests/test_src/test_helper.py | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/backend/common/src/helper.py b/backend/common/src/helper.py index dbf238ce..a29da9a2 100644 --- a/backend/common/src/helper.py +++ b/backend/common/src/helper.py @@ -110,6 +110,8 @@ def date_parser(timestamp: int | str | None) -> str | None: if isinstance(timestamp, int): date_obj = datetime.fromtimestamp(timestamp, tz=timezone.utc) + elif isinstance(timestamp, str) and timestamp.isdigit(): + date_obj = datetime.fromtimestamp(int(timestamp), tz=timezone.utc) elif isinstance(timestamp, str): date_obj = datetime.strptime(timestamp, "%Y-%m-%d") date_obj = date_obj.replace(tzinfo=timezone.utc) diff --git a/backend/common/tests/test_src/test_helper.py b/backend/common/tests/test_src/test_helper.py index 013e774b..73578a76 100644 --- a/backend/common/tests/test_src/test_helper.py +++ b/backend/common/tests/test_src/test_helper.py @@ -26,6 +26,13 @@ def test_date_parser_with_int(): assert date_parser(timestamp) == expected_date +def test_date_parser_with_digit(): + """unix timestamp""" + timestamp = "1621539600" + expected_date = "2021-05-20T19:40:00+00:00" + assert date_parser(timestamp) == expected_date + + def test_date_parser_with_str(): """iso timestamp""" date_str = "2021-05-21" From 360c621e6690d43cbb4073c847316646cc1578a3 Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 15 Dec 2025 21:24:01 +0700 Subject: [PATCH 13/29] fix overwrite none serialization --- backend/download/src/yt_dlp_handler.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/download/src/yt_dlp_handler.py b/backend/download/src/yt_dlp_handler.py index a5702a4f..ad95abe4 100644 --- a/backend/download/src/yt_dlp_handler.py +++ b/backend/download/src/yt_dlp_handler.py @@ -326,6 +326,9 @@ class DownloadPostProcess(DownloaderBase): for channel_id, value in self.channel_overwrites.items(): if "autodelete_days" in value: autodelete_days = value.get("autodelete_days") + if autodelete_days is None: + continue + print(f"{channel_id}: delete older than {autodelete_days}d") now_lte = str(self.now - autodelete_days * 24 * 60 * 60) must_list = [ From b04e3a935d799531c443bcba18dd910ff8e43306 Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 17 Dec 2025 21:58:01 +0700 Subject: [PATCH 14/29] refac for better hooks as library --- backend/video/src/subtitle.py | 54 ++++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/backend/video/src/subtitle.py b/backend/video/src/subtitle.py index 42e686c9..f49493a8 100644 --- a/backend/video/src/subtitle.py +++ b/backend/video/src/subtitle.py @@ -10,6 +10,7 @@ import os import re from datetime import datetime from operator import itemgetter +from typing import TypedDict import requests from common.src.env_settings import EnvironmentSettings @@ -18,6 +19,15 @@ from common.src.helper import rand_sleep, requests_headers from yt_dlp.utils import orderedSet_from_options +class SubtitleCue(TypedDict): + """describe single subtitle queue""" + + start: str + end: str + text: str + idx: int + + class YoutubeSubtitle: """handle video subtitle functionality""" @@ -74,8 +84,7 @@ class YoutubeSubtitle: # not supported yet continue - video_media_url = self.video.json_data["media_url"] - media_url = video_media_url.replace(".mp4", f".{lang}.vtt") + media_url = self.get_media_url(lang) if not all_formats: # no subtitles found continue @@ -93,6 +102,12 @@ class YoutubeSubtitle: return candidate_subtitles + def get_media_url(self, lang: str) -> str: + """get media url""" + video_media_url = self.video.json_data["media_url"] + media_url = video_media_url.replace(".mp4", f".{lang}.vtt") + return media_url + def get_es_subtitles(self) -> list[dict]: """get subtitles from elastic""" data = { @@ -135,17 +150,18 @@ class YoutubeSubtitle: continue subtitle_str = parser.get_subtitle_str() - self._write_subtitle_file(dest_path, subtitle_str) + self.write_subtitle_file(dest_path, subtitle_str) if self.video.config["downloads"]["subtitle_index"]: - query_str = parser.create_bulk_import(self.video, source) - self._index_subtitle(query_str) + documents = parser.create_documents(self.video, source) + query_str = parser.create_bulk_import(documents) + self.index_subtitle(query_str) indexed.append(subtitle) rand_sleep(self.video.config) return indexed - def _write_subtitle_file(self, dest_path, subtitle_str): + def write_subtitle_file(self, dest_path, subtitle_str): """write subtitle file to disk""" # create folder here for first video of channel os.makedirs(os.path.split(dest_path)[0], exist_ok=True) @@ -158,7 +174,7 @@ class YoutubeSubtitle: os.chown(dest_path, host_uid, host_gid) @staticmethod - def _index_subtitle(query_str): + def index_subtitle(query_str): """send subtitle to es for indexing""" _, _ = ElasticWrap("_bulk").post(data=query_str, ndjson=True) @@ -190,15 +206,14 @@ class YoutubeSubtitle: class SubtitleParser: """parse subtitle str from youtube""" - def __init__(self, subtitle_str, lang, source): + def __init__(self, subtitle_str: str, lang: str, source: str) -> None: self.subtitle_raw = json.loads(subtitle_str) self.lang = lang self.source = source - self.all_cues = False + self.all_cues: list[SubtitleCue] = [] - def process(self): + def process(self) -> None: """extract relevant que data""" - self.all_cues = [] all_events = self.subtitle_raw.get("events") if not all_events: @@ -213,12 +228,12 @@ class SubtitleParser: print(f"skipping subtitle event without content: {event}") continue - cue = { - "start": self._ms_conv(event["tStartMs"]), - "end": self._ms_conv(event["tStartMs"] + event["dDurationMs"]), - "text": "".join([i.get("utf8") for i in event["segs"]]), - "idx": idx + 1, - } + cue = SubtitleCue( + start=self._ms_conv(event["tStartMs"]), + end=self._ms_conv(event["tStartMs"] + event["dDurationMs"]), + text="".join([i.get("utf8") for i in event["segs"]]), + idx=idx + 1, + ) self.all_cues.append(cue) @staticmethod @@ -272,9 +287,8 @@ class SubtitleParser: return subtitle_str - def create_bulk_import(self, video, source): + def create_bulk_import(self, documents): """subtitle lines for es import""" - documents = self._create_documents(video, source) bulk_list = [] for document in documents: @@ -288,7 +302,7 @@ class SubtitleParser: return query_str - def _create_documents(self, video, source): + def create_documents(self, video, source): """process documents""" documents = self._chunk_list(video.youtube_id) channel = video.json_data.get("channel") From 2d40a340ea0e8d61a5f4efcae9cd3c38f00a7f75 Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 17 Dec 2025 21:58:36 +0700 Subject: [PATCH 15/29] refac use comment_comments for comment count update --- backend/video/src/comments.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/backend/video/src/comments.py b/backend/video/src/comments.py index 4b145a75..d72ead6c 100644 --- a/backend/video/src/comments.py +++ b/backend/video/src/comments.py @@ -142,14 +142,13 @@ class Comments: def upload_comments(self): """upload comments to es""" - if not self.is_activated: - return - print(f"{self.youtube_id}: upload comments") _, _ = ElasticWrap(self.es_path).put(self.json_data) vid_path = f"ta_video/_update/{self.youtube_id}" - data = {"doc": {"comment_count": len(self.comments_format)}} + data = { + "doc": {"comment_count": len(self.json_data["comment_comments"])} + } _, _ = ElasticWrap(vid_path).post(data=data) def delete_comments(self): From 8b4bbc8fa8285095ff5f63eed8eb61d660797d3b Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 17 Dec 2025 21:59:17 +0700 Subject: [PATCH 16/29] add comments serializer --- backend/video/serializers.py | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/backend/video/serializers.py b/backend/video/serializers.py index 55d229f0..bc90a1c9 100644 --- a/backend/video/serializers.py +++ b/backend/video/serializers.py @@ -60,6 +60,23 @@ class StreamItemSerializer(serializers.Serializer): height = serializers.IntegerField(required=False) +class SubtitleFragmentSerializer(serializers.Serializer): + """serialize subtitle fragment""" + + subtitle_index = serializers.IntegerField() + subtitle_line = serializers.CharField() + subtitle_start = serializers.CharField() + subtitle_fragment_id = serializers.CharField() + subtitle_end = serializers.CharField() + youtube_id = serializers.CharField() + title = serializers.CharField() + subtitle_channel = serializers.CharField() + subtitle_channel_id = serializers.CharField() + subtitle_last_refresh = serializers.IntegerField() + subtitle_lang = serializers.CharField() + subtitle_source = serializers.ChoiceField(choices=["user", "auto"]) + + class SubtitleItemSerializer(serializers.Serializer): """serialize subtitle item""" @@ -76,8 +93,8 @@ class VideoSerializer(serializers.Serializer): active = serializers.BooleanField() category = serializers.ListField(child=serializers.CharField()) - channel = ChannelSerializer() - comment_count = serializers.IntegerField(allow_null=True) + channel = ChannelSerializer(required=False) + comment_count = serializers.IntegerField(allow_null=True, required=False) date_downloaded = serializers.IntegerField() description = serializers.CharField() media_size = serializers.IntegerField() @@ -85,7 +102,7 @@ class VideoSerializer(serializers.Serializer): player = PlayerSerializer() playlist = serializers.ListField(child=serializers.CharField()) published = serializers.CharField() - sponsorblock = SponsorBlockSerializer(allow_null=True) + sponsorblock = SponsorBlockSerializer(allow_null=True, required=False) stats = StatsSerializer() streams = StreamItemSerializer(many=True) subtitles = SubtitleItemSerializer(many=True) @@ -153,7 +170,16 @@ class CommentItemSerializer(serializers.Serializer): comment_author_thumbnail = serializers.URLField() comment_author_is_uploader = serializers.BooleanField() comment_parent = serializers.CharField() - comment_replies = CommentThreadItemSerializer(many=True) + comment_replies = CommentThreadItemSerializer(many=True, required=False) + + +class CommentsSerializer(serializers.Serializer): + """serialize comments as indexed""" + + youtube_id = serializers.CharField() + comment_last_refresh = serializers.IntegerField() + comment_channel_id = serializers.CharField() + comment_comments = CommentItemSerializer(many=True) class PlaylistNavMetaSerializer(serializers.Serializer): From 4e14f19bee8a219f853cba4fac7b42f2f537f543 Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 17 Dec 2025 22:00:14 +0700 Subject: [PATCH 17/29] add IndexFromEmbed class --- backend/video/src/meta_embed.py | 242 ++++++++++++++++++++++++++++++++ 1 file changed, 242 insertions(+) diff --git a/backend/video/src/meta_embed.py b/backend/video/src/meta_embed.py index 0a7f62e1..914883be 100644 --- a/backend/video/src/meta_embed.py +++ b/backend/video/src/meta_embed.py @@ -1,7 +1,23 @@ """bulk metadata embedding""" +import json +import os +import shutil + +from appsettings.src.config import AppConfig, AppConfigType +from channel.serializers import ChannelSerializer +from channel.src.index import YoutubeChannel +from common.src.env_settings import EnvironmentSettings from common.src.es_connect import ElasticWrap, IndexPaginate +from mutagen.mp4 import MP4, MP4FreeForm +from video.serializers import ( + CommentsSerializer, + SubtitleFragmentSerializer, + VideoSerializer, +) +from video.src.comments import Comments from video.src.index import YoutubeVideo +from video.src.subtitle import SubtitleParser, YoutubeSubtitle class MetadataEmbed: @@ -49,3 +65,229 @@ class MetadataEmbedCallback: for video in self.source: youtube_id = video["_source"]["youtube_id"] YoutubeVideo(youtube_id).embed_metadata() + + +class IndexFromEmbed: + """restore from embedded metadata, potential untrusted""" + + VIDEOS_BASE = EnvironmentSettings.MEDIA_DIR + HOST_UID = EnvironmentSettings.HOST_UID + HOST_GID = EnvironmentSettings.HOST_GID + + def __init__( + self, + file_path: str, + reset_user_conf: bool = True, + config: AppConfigType | None = None, + ): + self.file_path = file_path + self.reset_user_conf = reset_user_conf + self.config = config + + def run_index(self) -> None: + """run index""" + if not self.config: + self.config = AppConfig().config + + json_embed = self._get_embedded() + if not json_embed: + return + + channel_data_clean = self.index_channel(json_embed) + video = self.index_video(json_embed, channel_data_clean) + self.archive_video(video) + self.index_subtitles(json_embed, video) + self.index_comments(json_embed) + + def _get_embedded(self) -> dict | None: + """get embedded metadata""" + video = MP4(self.file_path) + ta_data = video.get("----:com.tubearchivist:ta") + if not ta_data: + return None + + if not isinstance(ta_data, list): + raise ValueError(f"[{self.file_path}] unexpected embedded data") + + to_decode = ta_data[0] + + if not isinstance(to_decode, MP4FreeForm): + raise ValueError(f"[{self.file_path}] unexpected embedded data") + + try: + json_embed = json.loads(to_decode.decode()) + except Exception as exc: # pylint: disable=broad-exception-caught + err = f"[{self.file_path}] embedded decoding failed: {str(exc)}" + raise ValueError(err) from exc + + if not json_embed.get("video"): + err = f"[{self.file_path}] embedded does not contain video key" + raise ValueError(err) + + return json_embed + + def index_channel(self, json_embed): + """index channel""" + channel_data = json_embed["video"].get("channel") + if not channel_data: + raise ValueError(f"[{self.file_path}] missing channel metadata") + + serializer = ChannelSerializer(data=channel_data) + is_valid = serializer.is_valid() + if not is_valid: + err = serializer.errors + raise ValueError( + f"[{self.file_path}] channel serializer failed: {err}" + ) + + channel_data_clean = dict(serializer.data) + if self.reset_user_conf: + if "channel_overwrites" in channel_data_clean: + channel_data_clean.pop("channel_overwrites") + + channel_data_clean["channel_subscribed"] = False + + channel = YoutubeChannel(youtube_id=channel_data_clean["channel_id"]) + channel.build_json(upload=True) + if channel.json_data: + return + + channel.json_data = channel_data_clean + channel.upload_to_es() + + return channel_data_clean + + def index_video(self, json_embed, channel_data_clean): + """index video""" + video_data = json_embed["video"] + video_data.pop("channel") + + serializer = VideoSerializer(data=video_data) + is_valid = serializer.is_valid() + if not is_valid: + err = serializer.errors + raise ValueError( + f"[{self.file_path}] video serializer failed: {err}" + ) + + video_data_clean = dict(serializer.data) + if self.reset_user_conf: + video_data_clean["player"]["watched"] = False + + video = YoutubeVideo(youtube_id=video_data_clean["youtube_id"]) + video.build_json() + if video.json_data: + video.upload_to_es() + return + + video_data_clean["channel"] = channel_data_clean + video.json_data = video_data_clean + video.upload_to_es() + + return video + + def archive_video(self, video): + """archive video file""" + channel_id = video.json_data["channel"]["channel_id"] + folder = os.path.join(self.VIDEOS_BASE, channel_id) + if not os.path.exists(folder): + os.makedirs(folder) + if self.HOST_UID and self.HOST_GID: + os.chown(folder, self.HOST_UID, self.HOST_GID) + + new_path = os.path.join(folder, f"{video.youtube_id}.mp4") + if self.file_path == new_path: + # already archived + return + + shutil.move(self.file_path, new_path, copy_function=shutil.copyfile) + if self.HOST_UID and self.HOST_GID: + os.chown(new_path, self.HOST_UID, self.HOST_GID) + + def index_subtitles(self, json_embed, video): + """index subtitles""" + subtitle_data = json_embed.get("subtitles") + if not subtitle_data: + return + + serializer = SubtitleFragmentSerializer(data=subtitle_data, many=True) + is_valid = serializer.is_valid() + if not is_valid: + err = serializer.errors + raise ValueError( + f"[{self.file_path}] subtitle serializer failed: {err}" + ) + + self._process_embedded(video, subtitle_data=serializer.data) + + def _process_embedded(self, video, subtitle_data): + """process single embedded subtitle""" + embedded_subs = { + (i["subtitle_lang"], i["subtitle_source"]) for i in subtitle_data + } + subs = YoutubeSubtitle(video) + + if video.youtube_meta: + relevant_subtitles = subs.get_subtitles() + indexed = subs.download_subtitles(relevant_subtitles) + else: + indexed = [] + + for embedded_lang, embedded_source in embedded_subs: + for sub_indexed in indexed: + if ( + sub_indexed.get("lang") == embedded_lang + and sub_indexed.get("source") == embedded_source + ): + # indexed from remote + continue + + segments = [ + i + for i in subtitle_data + if i["subtitle_lang"] == embedded_lang + and i["subtitle_source"] == embedded_source + ] + to_index = sorted(segments, key=lambda d: d["subtitle_index"]) + parser = SubtitleParser( + subtitle_str="{}", lang=embedded_lang, source=embedded_source + ) + + for segment in to_index: + parser.all_cues.append( + { + "start": segment["subtitle_start"], + "end": segment["subtitle_end"], + "text": segment["subtitle_line"], + "idx": segment["subtitle_index"], + } + ) + + subtitle_str = parser.get_subtitle_str() + query_str = parser.create_bulk_import(to_index) + subs.index_subtitle(query_str) + + media_url = subs.get_media_url(lang=embedded_lang) + dest_path = os.path.join(self.VIDEOS_BASE, media_url) + subs.write_subtitle_file(dest_path, subtitle_str) + + def index_comments(self, json_embed): + """index comments""" + comment_data = json_embed.get("comments") + if not comment_data: + return + + serializer = CommentsSerializer(data=comment_data) + is_valid = serializer.is_valid() + if not is_valid: + err = serializer.errors + raise ValueError( + f"[{self.file_path}] comments serializer failed: {err}" + ) + + comments = Comments(youtube_id=serializer.data["youtube_id"]) + comments.build_json() + if not comments.json_data: + comments.json_data = dict(serializer.data) + + comments.upload_comments() From e78a48bf3c80059eec994c491d4d256d56847729 Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 24 Dec 2025 14:50:28 +0700 Subject: [PATCH 18/29] refac IndexFromEmbed, follow user config --- backend/video/src/meta_embed.py | 101 ++++++++++++++++++++------------ 1 file changed, 63 insertions(+), 38 deletions(-) diff --git a/backend/video/src/meta_embed.py b/backend/video/src/meta_embed.py index 914883be..6a1e0edf 100644 --- a/backend/video/src/meta_embed.py +++ b/backend/video/src/meta_embed.py @@ -77,11 +77,11 @@ class IndexFromEmbed: def __init__( self, file_path: str, - reset_user_conf: bool = True, + use_user_conf: bool = True, config: AppConfigType | None = None, ): self.file_path = file_path - self.reset_user_conf = reset_user_conf + self.use_user_conf = use_user_conf self.config = config def run_index(self) -> None: @@ -141,21 +141,19 @@ class IndexFromEmbed: ) channel_data_clean = dict(serializer.data) - if self.reset_user_conf: + if not self.use_user_conf: if "channel_overwrites" in channel_data_clean: channel_data_clean.pop("channel_overwrites") channel_data_clean["channel_subscribed"] = False channel = YoutubeChannel(youtube_id=channel_data_clean["channel_id"]) - channel.build_json(upload=True) - if channel.json_data: - return + channel.get_from_es() + if not channel.json_data: + channel.json_data = channel_data_clean + channel.upload_to_es() - channel.json_data = channel_data_clean - channel.upload_to_es() - - return channel_data_clean + return channel.json_data def index_video(self, json_embed, channel_data_clean): """index video""" @@ -171,18 +169,15 @@ class IndexFromEmbed: ) video_data_clean = dict(serializer.data) - if self.reset_user_conf: + if not self.use_user_conf: video_data_clean["player"]["watched"] = False - video = YoutubeVideo(youtube_id=video_data_clean["youtube_id"]) - video.build_json() - if video.json_data: - video.upload_to_es() - return - video_data_clean["channel"] = channel_data_clean - video.json_data = video_data_clean - video.upload_to_es() + video = YoutubeVideo(youtube_id=video_data_clean["youtube_id"]) + video.get_from_es() + if not video.json_data: + video.json_data = video_data_clean + video.upload_to_es() return video @@ -218,29 +213,25 @@ class IndexFromEmbed: f"[{self.file_path}] subtitle serializer failed: {err}" ) - self._process_embedded(video, subtitle_data=serializer.data) + self._process_embedded_subs(video, subtitle_data=serializer.data) - def _process_embedded(self, video, subtitle_data): + def _process_embedded_subs(self, video, subtitle_data): """process single embedded subtitle""" embedded_subs = { (i["subtitle_lang"], i["subtitle_source"]) for i in subtitle_data } subs = YoutubeSubtitle(video) - - if video.youtube_meta: - relevant_subtitles = subs.get_subtitles() - indexed = subs.download_subtitles(relevant_subtitles) - else: - indexed = [] + response = subs.get_es_subtitles() + indexed = { + (i["subtitle_lang"], i["subtitle_source"]) for i in response + } for embedded_lang, embedded_source in embedded_subs: - for sub_indexed in indexed: - if ( - sub_indexed.get("lang") == embedded_lang - and sub_indexed.get("source") == embedded_source - ): - # indexed from remote - continue + needs_processing = self._process_subtitle( + indexed, embedded_lang, embedded_source + ) + if not needs_processing: + continue segments = [ i @@ -271,6 +262,35 @@ class IndexFromEmbed: dest_path = os.path.join(self.VIDEOS_BASE, media_url) subs.write_subtitle_file(dest_path, subtitle_str) + def _process_subtitle( + self, indexed, embedded_lang, embedded_source + ) -> bool: + """check if subtitle should be processed""" + for sub_indexed in indexed: + if ( + sub_indexed.get("lang") == embedded_lang + and sub_indexed.get("source") == embedded_source + ): + # already indexed + return False + + if not self.use_user_conf: + return True + + if not self.config: + return False + + langs = self.config["downloads"]["subtitle"] + source = self.config["downloads"]["subtitle_source"] + if not langs or not source: + return False + + lang_codes = [i.strip() for i in langs.split(",")] + if embedded_lang not in lang_codes: + return True + + return False + def index_comments(self, json_embed): """index comments""" comment_data = json_embed.get("comments") @@ -285,9 +305,14 @@ class IndexFromEmbed: f"[{self.file_path}] comments serializer failed: {err}" ) - comments = Comments(youtube_id=serializer.data["youtube_id"]) - comments.build_json() - if not comments.json_data: - comments.json_data = dict(serializer.data) + if self.use_user_conf: + if self.config and not self.config["downloads"]["comment_max"]: + return + comments = Comments(youtube_id=serializer.data["youtube_id"]) + existing = comments.get_es_comments() + if existing: + return + + comments.json_data = dict(serializer.data) comments.upload_comments() From fdadcfd48b764b1da6c3ce1b5dfd7193008f1a44 Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 24 Dec 2025 14:55:09 +0700 Subject: [PATCH 19/29] fix delete channel tvart --- backend/download/src/thumbnails.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/download/src/thumbnails.py b/backend/download/src/thumbnails.py index 2d9d4739..f9617973 100644 --- a/backend/download/src/thumbnails.py +++ b/backend/download/src/thumbnails.py @@ -228,10 +228,13 @@ class ThumbManager(ThumbManagerBase): """delete all artwork of channel""" thumb = os.path.join(self.CHANNEL_DIR, f"{self.item_id}_thumb.jpg") banner = os.path.join(self.CHANNEL_DIR, f"{self.item_id}_banner.jpg") + tv = os.path.join(self.CHANNEL_DIR, f"{self.item_id}_tvart.jpg") if os.path.exists(thumb): os.remove(thumb) if os.path.exists(banner): os.remove(banner) + if os.path.exists(tv): + os.remove(tv) def delete_playlist_thumb(self): """delete playlist thumbnail""" From 16bd13abc92988e6f3e07b954f928838210ba5fc Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 24 Dec 2025 15:34:29 +0700 Subject: [PATCH 20/29] add cookie to subtitle fetch --- backend/video/src/subtitle.py | 59 ++++++++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 15 deletions(-) diff --git a/backend/video/src/subtitle.py b/backend/video/src/subtitle.py index f49493a8..68868eb9 100644 --- a/backend/video/src/subtitle.py +++ b/backend/video/src/subtitle.py @@ -16,6 +16,7 @@ import requests from common.src.env_settings import EnvironmentSettings from common.src.es_connect import ElasticWrap, IndexPaginate from common.src.helper import rand_sleep, requests_headers +from download.src.yt_dlp_base import CookieHandler from yt_dlp.utils import orderedSet_from_options @@ -64,7 +65,9 @@ class YoutubeSubtitle: ) ] except re.error as e: - raise ValueError(f"wrong regex in subtitle config: {e.pattern}") + raise ValueError( + f"wrong regex in subtitle config: {e.pattern}" + ) from e return relevant_subtitles @@ -128,22 +131,12 @@ class YoutubeSubtitle: dest_path = os.path.join(videos_base, subtitle["media_url"]) source = subtitle["source"] lang = subtitle.get("lang") - response = requests.get( - subtitle["url"], headers=requests_headers(), timeout=30 - ) - if not response.ok: - subtitle_key = f"{self.video.youtube_id}-{lang}" - print(f"{subtitle_key}: failed to download subtitle") - print(response.text) - rand_sleep(self.video.config) + + response_text = self._make_request(subtitle["url"], lang) + if not response_text: continue - if not response.text: - print(f"{subtitle_key}: skip empty subtitle") - rand_sleep(self.video.config) - continue - - parser = SubtitleParser(response.text, lang, source) + parser = SubtitleParser(response_text, lang, source) parser.process() if not parser.all_cues: rand_sleep(self.video.config) @@ -161,6 +154,42 @@ class YoutubeSubtitle: return indexed + def _make_request(self, url: str, lang: str | None) -> str | None: + """make the request""" + request_kwargs: dict = { + "timeout": 30, + "headers": requests_headers(), + } + if self.video.config["downloads"].get("cookie_import"): + cookie = CookieHandler(self.video.config).get() + if cookie: + cookies_txt = cookie.read() + jar = requests.cookies.RequestsCookieJar() + for line in cookies_txt.split("\n"): + words = line.split() + if (len(words) == 7) and (words[0] != "#"): + jar.set( + words[5], words[6], domain=words[0], path=words[2] + ) + + request_kwargs["cookies"] = jar + + response = requests.get(url, **request_kwargs) + + if not response.ok: + subtitle_key = f"{self.video.youtube_id}-{lang}" + print(f"{subtitle_key}: failed to download subtitle") + print(response.text) + rand_sleep(self.video.config) + return None + + if not response.text: + print(f"{subtitle_key}: skip empty subtitle") + rand_sleep(self.video.config) + return None + + return response.text + def write_subtitle_file(self, dest_path, subtitle_str): """write subtitle file to disk""" # create folder here for first video of channel From c9d4a45e39d9848889c066b381085896b36f8736 Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 25 Dec 2025 12:08:03 +0700 Subject: [PATCH 21/29] implement channel artwork embed in video --- backend/download/src/thumbnails.py | 88 ++++++++++++++++---------- backend/download/src/yt_dlp_handler.py | 14 ++-- backend/video/src/index.py | 20 +++++- 3 files changed, 75 insertions(+), 47 deletions(-) diff --git a/backend/download/src/thumbnails.py b/backend/download/src/thumbnails.py index f9617973..f387f2da 100644 --- a/backend/download/src/thumbnails.py +++ b/backend/download/src/thumbnails.py @@ -4,9 +4,7 @@ functionality: - check for missing thumbnails """ -import base64 import os -from io import BytesIO from time import sleep import requests @@ -14,7 +12,7 @@ from common.src.env_settings import EnvironmentSettings from common.src.es_connect import ElasticWrap, IndexPaginate from common.src.helper import is_missing from mutagen.mp4 import MP4, MP4Cover -from PIL import Image, ImageFile, ImageFilter, UnidentifiedImageError +from PIL import Image, ImageFile, UnidentifiedImageError ImageFile.LOAD_TRUNCATED_IMAGES = True @@ -23,6 +21,7 @@ class ThumbManagerBase: """base class for thumbnail management""" CACHE_DIR = EnvironmentSettings.CACHE_DIR + MEDIA_DIR = EnvironmentSettings.MEDIA_DIR VIDEO_DIR = os.path.join(CACHE_DIR, "videos") CHANNEL_DIR = os.path.join(CACHE_DIR, "channels") PLAYLIST_DIR = os.path.join(CACHE_DIR, "playlists") @@ -217,6 +216,56 @@ class ThumbManager(ThumbManagerBase): img_raw = img_raw.resize((336, 189)) img_raw.convert("RGB").save(thumb_path) + def embed_video_art(self, json_data: dict): + """embed video artwork""" + file_path = os.path.join(self.MEDIA_DIR, json_data["media_url"]) + video = MP4(file_path) + + thumb_path = os.path.join(self.CACHE_DIR, self.vid_thumb_path()) + if os.path.exists(thumb_path): + with open(thumb_path, "rb") as f: + cover_data = f.read() + + video["covr"] = [ + MP4Cover(cover_data, imageformat=MP4Cover.FORMAT_JPEG) + ] + + channel_id = json_data["channel"]["channel_id"] + banner_path = os.path.join( + self.CHANNEL_DIR, f"{channel_id}_banner.jpg" + ) + if os.path.exists(banner_path): + with open(banner_path, "rb") as f: + banner_data = f.read() + + video["----:com.tubearchivist:channel_banner"] = [ + MP4Cover(banner_data, imageformat=MP4Cover.FORMAT_JPEG) + ] + + channel_icon_path = os.path.join( + self.CHANNEL_DIR, f"{channel_id}_thumb.jpg" + ) + if os.path.exists(channel_icon_path): + with open(channel_icon_path, "rb") as f: + icon_data = f.read() + + video["----:com.tubearchivist:channel_icon"] = [ + MP4Cover(icon_data, imageformat=MP4Cover.FORMAT_JPEG) + ] + + channel_tv_path = os.path.join( + self.CHANNEL_DIR, f"{channel_id}_tvart.jpg" + ) + if os.path.exists(channel_tv_path): + with open(channel_tv_path, "rb") as f: + tv_data = f.read() + + video["----:com.tubearchivist:channel_tv"] = [ + MP4Cover(tv_data, imageformat=MP4Cover.FORMAT_JPEG) + ] + + video.save() + def delete_video_thumb(self): """delete video thumbnail if exists""" thumb_path = self.vid_thumb_path() @@ -242,20 +291,6 @@ class ThumbManager(ThumbManagerBase): if os.path.exists(thumb_path): os.remove(thumb_path) - def get_vid_base64_blur(self): - """return base64 encoded placeholder""" - file_path = os.path.join(self.CACHE_DIR, self.vid_thumb_path()) - img_raw = Image.open(file_path) - img_raw.thumbnail((img_raw.width // 20, img_raw.height // 20)) - img_blur = img_raw.filter(ImageFilter.BLUR) - buffer = BytesIO() - img_blur.save(buffer, format="JPEG") - img_data = buffer.getvalue() - img_base64 = base64.b64encode(img_data).decode() - data_url = f"data:image/jpg;base64,{img_base64}" - - return data_url - class ValidatorCallback: """handle callback validate thumbnails page by page""" @@ -484,9 +519,7 @@ class ThumbFilesystem: class EmbedCallback: """callback class to embed thumbnails""" - CACHE_DIR = EnvironmentSettings.CACHE_DIR MEDIA_DIR = EnvironmentSettings.MEDIA_DIR - FORMAT = MP4Cover.FORMAT_JPEG def __init__(self, source, index_name, counter=0): self.source = source @@ -497,19 +530,4 @@ class EmbedCallback: """run embed""" for video in self.source: video_id = video["_source"]["youtube_id"] - media_url = os.path.join( - self.MEDIA_DIR, video["_source"]["media_url"] - ) - thumb_path = os.path.join( - self.CACHE_DIR, ThumbManager(video_id).vid_thumb_path() - ) - if os.path.exists(thumb_path): - self.embed(media_url, thumb_path) - - def embed(self, media_url, thumb_path): - """embed thumb in single media file""" - video = MP4(media_url) - with open(thumb_path, "rb") as f: - video["covr"] = [MP4Cover(f.read(), imageformat=self.FORMAT)] - - video.save() + ThumbManager(video_id).embed_video_art(video["_source"]) diff --git a/backend/download/src/yt_dlp_handler.py b/backend/download/src/yt_dlp_handler.py index ad95abe4..08017a3c 100644 --- a/backend/download/src/yt_dlp_handler.py +++ b/backend/download/src/yt_dlp_handler.py @@ -196,15 +196,6 @@ class VideoDownloader(DownloaderBase): } ) - if self.config["downloads"]["add_thumbnail"]: - postprocessors.append( - { - "key": "EmbedThumbnail", - "already_have_thumbnail": True, - } - ) - self.obs["writethumbnail"] = True - self.obs["postprocessors"] = postprocessors def _set_overwrites(self, obs: dict, channel_id: str) -> None: @@ -491,7 +482,10 @@ class DownloadPostProcess(DownloaderBase): def embed_metadata(self): """embed metadata in media file""" - if not self.config["downloads"].get("add_metadata"): + meta = self.config["downloads"].get("add_metadata") + thumb = self.config["downloads"].get("add_thumbnail") + + if not meta and not thumb: return queue = RedisQueue(self.VIDEO_QUEUE) diff --git a/backend/video/src/index.py b/backend/video/src/index.py index 1d5cf839..11a5775f 100644 --- a/backend/video/src/index.py +++ b/backend/video/src/index.py @@ -424,9 +424,13 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle): def embed_metadata(self): """embed metadata for video""" - if not self.config["downloads"].get("add_metadata"): - return + if self.config["downloads"].get("add_metadata"): + self._embed_text_data() + if self.config["downloads"].get("add_thumbnail"): + self._embed_artwork() + + def _embed_text_data(self): print(f"{self.youtube_id}: embed metadata") if not self.json_data: self.get_from_es() @@ -484,6 +488,18 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle): return to_embed + def _embed_artwork(self): + """embed artwork""" + print(f"{self.youtube_id}: embed artwork") + if not self.json_data: + self.get_from_es() + + if not self.json_data: + print(f"{self.youtube_id}: skip embed, video not indexed") + return + + ThumbManager(self.youtube_id).embed_video_art(self.json_data) + def index_new_video(youtube_id, video_type=VideoTypeEnum.VIDEOS): """combined classes to create new video in index""" From 8309dc4feccaf10fd8b7a9a5b2de0113ee91beee Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 25 Dec 2025 14:38:28 +0700 Subject: [PATCH 22/29] fix embed channel ID missing --- backend/download/src/thumbnails.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/download/src/thumbnails.py b/backend/download/src/thumbnails.py index f387f2da..59856777 100644 --- a/backend/download/src/thumbnails.py +++ b/backend/download/src/thumbnails.py @@ -496,7 +496,7 @@ class ThumbFilesystem: """entry point""" data = { "query": {"match_all": {}}, - "_source": ["media_url", "youtube_id"], + "_source": ["media_url", "youtube_id", "channel.channel_id"], } paginate = IndexPaginate( index_name=self.INDEX_NAME, From 939def5120c7933ba0f00d38539bb7418d5cb43f Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 25 Dec 2025 15:53:23 +0700 Subject: [PATCH 23/29] reuse absolute path flag --- backend/download/src/thumbnails.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/download/src/thumbnails.py b/backend/download/src/thumbnails.py index 59856777..c4efa66f 100644 --- a/backend/download/src/thumbnails.py +++ b/backend/download/src/thumbnails.py @@ -221,7 +221,7 @@ class ThumbManager(ThumbManagerBase): file_path = os.path.join(self.MEDIA_DIR, json_data["media_url"]) video = MP4(file_path) - thumb_path = os.path.join(self.CACHE_DIR, self.vid_thumb_path()) + thumb_path = self.vid_thumb_path(absolute=True) if os.path.exists(thumb_path): with open(thumb_path, "rb") as f: cover_data = f.read() From a3cda2ca59974fd3188c85a2ee129f856b75ae95 Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 25 Dec 2025 15:53:59 +0700 Subject: [PATCH 24/29] embedd version for later comparison --- backend/video/src/index.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/video/src/index.py b/backend/video/src/index.py index 11a5775f..d62bf8c9 100644 --- a/backend/video/src/index.py +++ b/backend/video/src/index.py @@ -483,6 +483,7 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle): "comments": comments, "subtitles": subtitles, "playlists": playlists, + "version": settings.TA_VERSION, } ) From 5c3898e7b83507aa3019da667fa29dd4ed131414 Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 25 Dec 2025 15:59:10 +0700 Subject: [PATCH 25/29] implement artwork restore --- backend/video/src/meta_embed.py | 51 +++++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/backend/video/src/meta_embed.py b/backend/video/src/meta_embed.py index 6a1e0edf..1087929f 100644 --- a/backend/video/src/meta_embed.py +++ b/backend/video/src/meta_embed.py @@ -9,6 +9,7 @@ from channel.serializers import ChannelSerializer from channel.src.index import YoutubeChannel from common.src.env_settings import EnvironmentSettings from common.src.es_connect import ElasticWrap, IndexPaginate +from download.src.thumbnails import ThumbManager from mutagen.mp4 import MP4, MP4FreeForm from video.serializers import ( CommentsSerializer, @@ -71,6 +72,7 @@ class IndexFromEmbed: """restore from embedded metadata, potential untrusted""" VIDEOS_BASE = EnvironmentSettings.MEDIA_DIR + CACHE_DIR = EnvironmentSettings.CACHE_DIR HOST_UID = EnvironmentSettings.HOST_UID HOST_GID = EnvironmentSettings.HOST_GID @@ -101,8 +103,8 @@ class IndexFromEmbed: def _get_embedded(self) -> dict | None: """get embedded metadata""" - video = MP4(self.file_path) - ta_data = video.get("----:com.tubearchivist:ta") + video_mutagen = MP4(self.file_path) + ta_data = video_mutagen.get("----:com.tubearchivist:ta") if not ta_data: return None @@ -199,6 +201,51 @@ class IndexFromEmbed: if self.HOST_UID and self.HOST_GID: os.chown(new_path, self.HOST_UID, self.HOST_GID) + def restore_artwork(self, video): + """restore artwork if needed""" + video_mutagen = MP4(self.file_path) + + thumb = ThumbManager(video.youtube_id).vid_thumb_path(absolute=True) + self._restore_art_item(video_mutagen, "covr", thumb) + + channel_id = video.json_data["channel"]["channel_id"] + banner_path = os.path.join( + self.CACHE_DIR, "channels", f"{channel_id}_banner.jpg" + ) + self._restore_art_item( + video_mutagen, "----:com.tubearchivist:channel_banner", banner_path + ) + + channel_icon = os.path.join( + self.CACHE_DIR, "channels", f"{channel_id}_thumb.jpg" + ) + self._restore_art_item( + video_mutagen, "----:com.tubearchivist:channel_icon", channel_icon + ) + + tv_art_path = os.path.join( + self.CACHE_DIR, "channels", f"{channel_id}_tvart.jpg" + ) + self._restore_art_item( + video_mutagen, "----:com.tubearchivist:channel_tv", tv_art_path + ) + + def _restore_art_item( + self, video_mutagen, mutagen_key: str, target_path: str + ) -> None: + """restore single art item""" + if os.path.exists(target_path): + # don't overwrite + return + + art_item = video_mutagen.get(mutagen_key) + if not art_item and not isinstance(art_item, list): + # is not embedded + return + + with open(target_path, "wb") as f: + f.write(bytes(art_item[0])) + def index_subtitles(self, json_embed, video): """index subtitles""" subtitle_data = json_embed.get("subtitles") From 83280ddcc9cfb391b3678b73713ba251d3ab9f8a Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 26 Dec 2025 13:58:51 +0700 Subject: [PATCH 26/29] consolidate available info, switch manual and filesystem logic --- backend/appsettings/src/filesystem.py | 7 ++----- backend/appsettings/src/manual.py | 8 ++------ backend/video/src/comments.py | 4 +++- backend/video/src/index.py | 24 +++++++++--------------- backend/video/src/meta_embed.py | 6 +++++- 5 files changed, 21 insertions(+), 28 deletions(-) diff --git a/backend/appsettings/src/filesystem.py b/backend/appsettings/src/filesystem.py index f481d5c9..0a609289 100644 --- a/backend/appsettings/src/filesystem.py +++ b/backend/appsettings/src/filesystem.py @@ -8,7 +8,7 @@ import os from common.src.env_settings import EnvironmentSettings from common.src.es_connect import IndexPaginate from common.src.helper import ignore_filelist -from video.src.comments import CommentList +from video.src.comments import Comments from video.src.index import YoutubeVideo, index_new_video @@ -88,8 +88,5 @@ class Scanner: ) json_data = index_new_video(youtube_id) if json_data: + Comments(youtube_id).build_json(upload=True) YoutubeVideo(youtube_id).embed_metadata() - - comment_list = CommentList(task=self.task) - comment_list.add(video_ids=list(self.to_index)) - comment_list.index() diff --git a/backend/appsettings/src/manual.py b/backend/appsettings/src/manual.py index 6b67a010..3be2b52b 100644 --- a/backend/appsettings/src/manual.py +++ b/backend/appsettings/src/manual.py @@ -16,7 +16,7 @@ from common.src.env_settings import EnvironmentSettings from common.src.helper import ignore_filelist from download.src.thumbnails import ThumbManager from PIL import Image -from video.src.comments import CommentList +from video.src.comments import Comments from video.src.index import YoutubeVideo from yt_dlp.utils import ISO639Utils @@ -145,13 +145,9 @@ class ImportFolderScanner: print(f"manual import: {current_video}") ManualImport(current_video, config).run() + Comments(current_video["video_id"]).build_json(upload=True) YoutubeVideo(current_video["video_id"]).embed_metadata() - video_ids = [i["video_id"] for i in self.to_import] - comment_list = CommentList(task=self.task) - comment_list.add(video_ids=video_ids) - comment_list.index() - def _notify(self, idx, current_video): """send notification back to task""" filename = os.path.split(current_video["media"])[-1] diff --git a/backend/video/src/comments.py b/backend/video/src/comments.py index d72ead6c..368b5b0b 100644 --- a/backend/video/src/comments.py +++ b/backend/video/src/comments.py @@ -25,7 +25,7 @@ class Comments: self.is_activated = False self.comments_format = False - def build_json(self): + def build_json(self, upload: bool = False): """build json document for es""" print(f"{self.youtube_id}: get comments") self.check_config() @@ -44,6 +44,8 @@ class Comments: "comment_channel_id": channel_id, "comment_comments": self.comments_format, } + if upload: + self.upload_comments() def check_config(self): """read config if not attached""" diff --git a/backend/video/src/index.py b/backend/video/src/index.py index d62bf8c9..e039aaa8 100644 --- a/backend/video/src/index.py +++ b/backend/video/src/index.py @@ -424,14 +424,6 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle): def embed_metadata(self): """embed metadata for video""" - if self.config["downloads"].get("add_metadata"): - self._embed_text_data() - - if self.config["downloads"].get("add_thumbnail"): - self._embed_artwork() - - def _embed_text_data(self): - print(f"{self.youtube_id}: embed metadata") if not self.json_data: self.get_from_es() @@ -439,6 +431,15 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle): print(f"{self.youtube_id}: skip embed, video not indexed") return + if self.config["downloads"].get("add_metadata"): + self._embed_text_data() + + if self.config["downloads"].get("add_thumbnail"): + self._embed_artwork() + + def _embed_text_data(self): + """embed text metadata""" + print(f"{self.youtube_id}: embed metadata") video_base = EnvironmentSettings.MEDIA_DIR media_url = self.json_data.get("media_url") file_path = os.path.join(video_base, media_url) @@ -492,13 +493,6 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle): def _embed_artwork(self): """embed artwork""" print(f"{self.youtube_id}: embed artwork") - if not self.json_data: - self.get_from_es() - - if not self.json_data: - print(f"{self.youtube_id}: skip embed, video not indexed") - return - ThumbManager(self.youtube_id).embed_video_art(self.json_data) diff --git a/backend/video/src/meta_embed.py b/backend/video/src/meta_embed.py index 1087929f..e3be281c 100644 --- a/backend/video/src/meta_embed.py +++ b/backend/video/src/meta_embed.py @@ -1,4 +1,8 @@ -"""bulk metadata embedding""" +""" +Functionality: +- bulk metadata embedding +- restore from embedded metadata +""" import json import os From 7fa776a9192f2046a86c95d9f48a831518de9a9c Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 26 Dec 2025 14:44:10 +0700 Subject: [PATCH 27/29] add load more button on snapshot array --- frontend/src/pages/SettingsApplication.tsx | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/SettingsApplication.tsx b/frontend/src/pages/SettingsApplication.tsx index 238f6610..5cfacf6a 100644 --- a/frontend/src/pages/SettingsApplication.tsx +++ b/frontend/src/pages/SettingsApplication.tsx @@ -33,6 +33,7 @@ const SettingsApplication = () => { const { userConfig } = useUserConfigStore(); const [response, setResponse] = useState(); const [refresh, setRefresh] = useState(false); + const [visibleSnapshotCount, setVisibleSnapshotCount] = useState(10); const snapshots = response?.snapshots; const appSettingsConfig = response?.appSettingsConfig; @@ -976,10 +977,9 @@ const SettingsApplication = () => {


    {restoringSnapshot &&

    Snapshot restore started

    } - {!restoringSnapshot && - snapshots.snapshots && - snapshots.snapshots.map(snapshot => { - return ( + {!restoringSnapshot && snapshots.snapshots && ( + <> + {snapshots.snapshots?.slice(0, visibleSnapshotCount).map(snapshot => (