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 => (
- );
- })}
+ ))}
+ {visibleSnapshotCount < snapshots.snapshots.length && (
+